diff --git a/README.md b/README.md index d338cf6..3770683 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Bounty modules + +- [identity-recovery-risk-guard](./identity-recovery-risk-guard/README.md) - account recovery and session-risk controls for user/project access restoration. diff --git a/identity-recovery-risk-guard/README.md b/identity-recovery-risk-guard/README.md new file mode 100644 index 0000000..a3384fc --- /dev/null +++ b/identity-recovery-risk-guard/README.md @@ -0,0 +1,27 @@ +# Identity Recovery Risk Guard + +This module implements a focused User & Project Management slice for account recovery and session-risk review. It is intentionally not another broad RBAC or profile-management demo. Instead, it answers a specific operational question: + +> Should SCIBASE restore account access or linked-identity control when the user has sensitive project memberships, active sessions, and identity evidence that may not line up? + +The guard evaluates synthetic password resets, MFA resets, email changes, OAuth relinks, and SAML rebinds before project access is restored. + +## What it covers + +- Linked identities across email, ORCID, GitHub, Google, and SAML. +- MFA recovery evidence, backup codes, and institutional approval. +- Suspicious sessions, new devices, country changes, and recent failed-login clusters. +- Project exposure for owner/admin roles and sensitive object grants. +- Recovery packets with missing evidence and required reviewers. +- Project access holds, session revocation recommendations, and deterministic audit events. + +## Run locally + +```bash +npm run check +npm test +npm run demo +npm run demo:gif +``` + +The implementation is dependency-free and uses synthetic data only. diff --git a/identity-recovery-risk-guard/data/sample-recovery-cases.json b/identity-recovery-risk-guard/data/sample-recovery-cases.json new file mode 100644 index 0000000..5efde1e --- /dev/null +++ b/identity-recovery-risk-guard/data/sample-recovery-cases.json @@ -0,0 +1,172 @@ +{ + "generatedAt": "2026-05-16T15:00:00.000Z", + "policy": { + "criticalRiskThreshold": 80, + "highRiskThreshold": 55, + "projectHoldRiskThreshold": 55, + "trustedInstitutionDomains": ["miskatonic.edu", "northbridge-labs.org"], + "recentWindowHours": 72 + }, + "users": [ + { + "id": "user-ada", + "name": "Ada Monroe", + "primaryEmail": "ada@miskatonic.edu", + "institutionDomain": "miskatonic.edu", + "mfa": { "enrolled": true, "backupCodesIssued": true, "lastVerifiedAt": "2026-05-01T12:00:00.000Z" }, + "linkedIdentities": [ + { "provider": "email", "subject": "ada@miskatonic.edu", "verified": true, "linkedAt": "2025-02-01T12:00:00.000Z" }, + { "provider": "orcid", "subject": "0000-0002-1111-2222", "verified": true, "linkedAt": "2025-02-02T12:00:00.000Z" }, + { "provider": "github", "subject": "ada-research", "verified": true, "linkedAt": "2025-02-03T12:00:00.000Z" }, + { "provider": "saml", "subject": "ada@miskatonic.edu", "verified": true, "linkedAt": "2025-02-04T12:00:00.000Z" } + ] + }, + { + "id": "user-lina", + "name": "Lina Kwan", + "primaryEmail": "lina@northbridge-labs.org", + "institutionDomain": "northbridge-labs.org", + "mfa": { "enrolled": true, "backupCodesIssued": true, "lastVerifiedAt": "2026-05-14T09:00:00.000Z" }, + "linkedIdentities": [ + { "provider": "email", "subject": "lina@northbridge-labs.org", "verified": true, "linkedAt": "2025-04-01T12:00:00.000Z" }, + { "provider": "saml", "subject": "lina@northbridge-labs.org", "verified": true, "linkedAt": "2025-04-02T12:00:00.000Z" }, + { "provider": "orcid", "subject": "0000-0003-3333-4444", "verified": true, "linkedAt": "2025-04-03T12:00:00.000Z" } + ] + }, + { + "id": "user-omar", + "name": "Omar Silva", + "primaryEmail": "omar@example.org", + "institutionDomain": "example.org", + "mfa": { "enrolled": false, "backupCodesIssued": false, "lastVerifiedAt": null }, + "linkedIdentities": [ + { "provider": "email", "subject": "omar@example.org", "verified": true, "linkedAt": "2026-01-01T12:00:00.000Z" } + ] + } + ], + "projects": [ + { + "id": "project-quasar", + "title": "Quasar Proteomics Consortium", + "visibility": "institutional", + "sensitivity": "restricted", + "memberships": [ + { "userId": "user-ada", "role": "owner" }, + { "userId": "user-lina", "role": "reviewer" } + ], + "objectGrants": [ + { "userId": "user-ada", "objectId": "dataset-raw-human", "action": "download", "sensitivity": "restricted" }, + { "userId": "user-lina", "objectId": "notebook-qc", "action": "comment", "sensitivity": "internal" } + ] + }, + { + "id": "project-atlas", + "title": "Atlas Methods Review", + "visibility": "private", + "sensitivity": "internal", + "memberships": [ + { "userId": "user-lina", "role": "admin" }, + { "userId": "user-omar", "role": "viewer" } + ], + "objectGrants": [ + { "userId": "user-lina", "objectId": "review-notes", "action": "edit", "sensitivity": "internal" } + ] + } + ], + "sessions": [ + { + "id": "sess-ada-known", + "userId": "user-ada", + "startedAt": "2026-05-16T08:15:00.000Z", + "lastSeenAt": "2026-05-16T14:30:00.000Z", + "ipCountry": "US", + "deviceFingerprint": "macbook-lab-a", + "trustedDevice": true, + "assuranceLevel": "mfa" + }, + { + "id": "sess-ada-new", + "userId": "user-ada", + "startedAt": "2026-05-16T13:45:00.000Z", + "lastSeenAt": "2026-05-16T14:45:00.000Z", + "ipCountry": "RO", + "deviceFingerprint": "unknown-browser-77", + "trustedDevice": false, + "assuranceLevel": "password" + }, + { + "id": "sess-lina-known", + "userId": "user-lina", + "startedAt": "2026-05-16T11:20:00.000Z", + "lastSeenAt": "2026-05-16T14:55:00.000Z", + "ipCountry": "US", + "deviceFingerprint": "northbridge-managed-2", + "trustedDevice": true, + "assuranceLevel": "mfa" + }, + { + "id": "sess-omar-known", + "userId": "user-omar", + "startedAt": "2026-05-15T17:00:00.000Z", + "lastSeenAt": "2026-05-16T10:00:00.000Z", + "ipCountry": "US", + "deviceFingerprint": "home-laptop", + "trustedDevice": true, + "assuranceLevel": "password" + } + ], + "authEvents": [ + { "userId": "user-ada", "type": "failed_login", "at": "2026-05-16T12:52:00.000Z", "ipCountry": "RO", "deviceFingerprint": "unknown-browser-77", "success": false }, + { "userId": "user-ada", "type": "failed_login", "at": "2026-05-16T12:54:00.000Z", "ipCountry": "RO", "deviceFingerprint": "unknown-browser-77", "success": false }, + { "userId": "user-ada", "type": "failed_login", "at": "2026-05-16T12:58:00.000Z", "ipCountry": "RO", "deviceFingerprint": "unknown-browser-77", "success": false }, + { "userId": "user-ada", "type": "provider_unlinked", "at": "2026-05-16T13:18:00.000Z", "ipCountry": "RO", "deviceFingerprint": "unknown-browser-77", "provider": "github", "success": true }, + { "userId": "user-lina", "type": "saml_assertion_changed", "at": "2026-05-16T14:05:00.000Z", "ipCountry": "US", "deviceFingerprint": "northbridge-managed-2", "provider": "saml", "success": true }, + { "userId": "user-omar", "type": "password_reset_requested", "at": "2026-05-16T09:20:00.000Z", "ipCountry": "US", "deviceFingerprint": "home-laptop", "success": true } + ], + "recoveryRequests": [ + { + "id": "rec-ada-mfa-reset", + "userId": "user-ada", + "type": "mfa_reset", + "requestedAt": "2026-05-16T14:50:00.000Z", + "source": { "ipCountry": "RO", "deviceFingerprint": "unknown-browser-77" }, + "evidence": { + "emailVerified": false, + "institutionalAdminApproved": false, + "mfaBackupCode": false, + "orcidReverified": false, + "deviceTrusted": false + } + }, + { + "id": "rec-lina-saml-rebind", + "userId": "user-lina", + "type": "saml_rebind", + "requestedAt": "2026-05-16T14:35:00.000Z", + "targetProvider": "saml", + "targetSubject": "lina@contractor-mail.example", + "source": { "ipCountry": "US", "deviceFingerprint": "northbridge-managed-2" }, + "evidence": { + "emailVerified": true, + "institutionalAdminApproved": false, + "mfaBackupCode": true, + "orcidReverified": true, + "deviceTrusted": true + } + }, + { + "id": "rec-omar-password", + "userId": "user-omar", + "type": "password_reset", + "requestedAt": "2026-05-16T10:05:00.000Z", + "source": { "ipCountry": "US", "deviceFingerprint": "home-laptop" }, + "evidence": { + "emailVerified": true, + "institutionalAdminApproved": false, + "mfaBackupCode": false, + "orcidReverified": false, + "deviceTrusted": true + } + } + ] +} diff --git a/identity-recovery-risk-guard/docs/demo.gif b/identity-recovery-risk-guard/docs/demo.gif new file mode 100644 index 0000000..054dd83 Binary files /dev/null and b/identity-recovery-risk-guard/docs/demo.gif differ diff --git a/identity-recovery-risk-guard/docs/demo.mp4 b/identity-recovery-risk-guard/docs/demo.mp4 new file mode 100644 index 0000000..7cda7d6 Binary files /dev/null and b/identity-recovery-risk-guard/docs/demo.mp4 differ diff --git a/identity-recovery-risk-guard/docs/demo.svg b/identity-recovery-risk-guard/docs/demo.svg new file mode 100644 index 0000000..dc80206 --- /dev/null +++ b/identity-recovery-risk-guard/docs/demo.svg @@ -0,0 +1,26 @@ + + Identity Recovery Risk Guard demo + Synthetic recovery requests are scored before sensitive project access is restored. + + + Identity Recovery Risk Guard + Account recovery is checked against MFA evidence, sessions, linked identities, and project exposure. + + Recovery request + MFA reset + SAML rebind + + Risk factors + New device + Domain mismatch + + Access decision + Hold recovery + Freeze sensitive grants + + + + Audit digest + risk score, recovery packet, session action, project hold + + diff --git a/identity-recovery-risk-guard/docs/requirement-map.md b/identity-recovery-risk-guard/docs/requirement-map.md new file mode 100644 index 0000000..6a172e6 --- /dev/null +++ b/identity-recovery-risk-guard/docs/requirement-map.md @@ -0,0 +1,19 @@ +# Requirement Map + +This module maps issue #11 to a focused recovery-risk control layer for User & Project Management. + +| Issue #11 requirement | Implementation | +| --- | --- | +| Email/password login with 2FA | `mfa_reset` and `password_reset` requests evaluate MFA backup-code, verified email, and device evidence before access is restored. | +| OAuth integrations and account linking | Linked identity records model email, ORCID, GitHub, Google, and SAML providers; relinks are checked for subject changes. | +| Institutional login via SAML | `saml_rebind` requests require trusted institutional domains and institution-admin approval. | +| Account linking for unified identity | `linked_identity_subject_changed` findings detect identity subject drift before project access is restored. | +| Public/private profile and attribution safety | Risky recovery decisions hold profile attribution changes until identity is reviewed. | +| Project spaces and linked collaborators | Project memberships and object grants are scanned for owner/admin exposure and sensitive project access. | +| Role-based access | Owner/admin roles trigger project access holds during high-risk recovery. | +| Object-level control | Restricted datasets and download grants are held separately from general project membership. | +| Project audit log | `auditEvents` records deterministic hashes for risk scoring, decisions, review packets, project holds, and session actions. | + +## Distinctness + +Existing issue #11 submissions cover broad RBAC, workspace governance, member offboarding, institutional recertification, anonymous-review escrow, and identity merge/export. This slice focuses on account recovery and active-session risk before access restoration. It is designed to sit in front of those systems and prevent a compromised recovery flow from inheriting sensitive project access. diff --git a/identity-recovery-risk-guard/package.json b/identity-recovery-risk-guard/package.json new file mode 100644 index 0000000..548ce27 --- /dev/null +++ b/identity-recovery-risk-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "identity-recovery-risk-guard", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "check": "node --check src/identity-recovery-risk-guard.js && node --check scripts/demo.js && node --check scripts/write-demo-gif.js && node --check test/identity-recovery-risk-guard.test.js", + "test": "node --test test/identity-recovery-risk-guard.test.js", + "demo": "node scripts/demo.js", + "demo:gif": "node scripts/write-demo-gif.js" + } +} diff --git a/identity-recovery-risk-guard/scripts/demo.js b/identity-recovery-risk-guard/scripts/demo.js new file mode 100644 index 0000000..b4b7af1 --- /dev/null +++ b/identity-recovery-risk-guard/scripts/demo.js @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { evaluateIdentityRecoveryRisk } from "../src/identity-recovery-risk-guard.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-recovery-cases.json"), "utf8")); +const report = evaluateIdentityRecoveryRisk(sample); + +console.log(JSON.stringify({ + digest: report.evidenceDigest, + summary: report.summary, + blockedRecoveries: report.recoveryCases + .filter((item) => item.severity === "critical" || item.severity === "high") + .map((item) => ({ + requestId: item.requestId, + user: item.userName, + type: item.type, + severity: item.severity, + riskScore: item.riskScore, + factors: item.factors + })), + decisions: report.decisions.map((decision) => ({ + requestId: decision.requestId, + recovery: decision.recovery, + projectAccess: decision.projectAccess, + sessions: decision.sessions + })), + requiredReviewPackets: report.recoveryPackets.map((packet) => ({ + requestId: packet.requestId, + reviewers: packet.requiredReviewers, + missingEvidence: packet.missingEvidence + })) +}, null, 2)); diff --git a/identity-recovery-risk-guard/scripts/write-demo-gif.js b/identity-recovery-risk-guard/scripts/write-demo-gif.js new file mode 100644 index 0000000..3d9927c --- /dev/null +++ b/identity-recovery-risk-guard/scripts/write-demo-gif.js @@ -0,0 +1,118 @@ +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const out = join(root, "docs", "demo.gif"); + +function u16(value) { + return String.fromCharCode(value & 255, (value >> 8) & 255); +} + +function color(r, g, b) { + return String.fromCharCode(r, g, b); +} + +function minCodeSize(colorCount) { + return Math.max(2, Math.ceil(Math.log2(colorCount))); +} + +function packCodes(codes, width) { + let bits = 0; + let bitCount = 0; + const bytes = []; + for (const code of codes) { + bits |= code << bitCount; + bitCount += width; + while (bitCount >= 8) { + bytes.push(bits & 255); + bits >>= 8; + bitCount -= 8; + } + } + if (bitCount > 0) bytes.push(bits & 255); + return bytes; +} + +function imageData(indices, paletteSize) { + const size = minCodeSize(paletteSize); + const clear = 1 << size; + const end = clear + 1; + const width = size + 1; + const codes = [clear]; + for (let index = 0; index < indices.length; index += 4) { + if (index > 0) codes.push(clear); + codes.push(...indices.slice(index, index + 4)); + } + codes.push(end); + const bytes = packCodes(codes, width); + const blocks = []; + for (let index = 0; index < bytes.length; index += 255) { + const chunk = bytes.slice(index, index + 255); + blocks.push(String.fromCharCode(chunk.length, ...chunk)); + } + return String.fromCharCode(size) + blocks.join("") + "\x00"; +} + +function frame(width, height, indices, delayCs, paletteSize) { + return [ + "\x21\xF9\x04\x04", + u16(delayCs), + "\x00\x00", + "\x2C", + u16(0), + u16(0), + u16(width), + u16(height), + "\x00", + imageData(indices, paletteSize) + ].join(""); +} + +function makeFrame(width, height, alertIndex) { + const pixels = new Array(width * height).fill(0); + const fill = (x0, y0, x1, y1, idx) => { + for (let y = y0; y < y1; y += 1) { + for (let x = x0; x < x1; x += 1) pixels[y * width + x] = idx; + } + }; + + fill(0, 0, width, Math.floor(height * 0.15), 1); + fill(Math.floor(width * 0.04), Math.floor(height * 0.24), Math.floor(width * 0.30), Math.floor(height * 0.44), 2); + fill(Math.floor(width * 0.37), Math.floor(height * 0.24), Math.floor(width * 0.63), Math.floor(height * 0.44), alertIndex); + fill(Math.floor(width * 0.70), Math.floor(height * 0.24), Math.floor(width * 0.96), Math.floor(height * 0.44), 3); + fill(Math.floor(width * 0.08), Math.floor(height * 0.60), Math.floor(width * 0.30), Math.floor(height * 0.72), 4); + fill(Math.floor(width * 0.39), Math.floor(height * 0.60), Math.floor(width * 0.61), Math.floor(height * 0.72), 5); + fill(Math.floor(width * 0.70), Math.floor(height * 0.60), Math.floor(width * 0.92), Math.floor(height * 0.72), 6); + fill(Math.floor(width * 0.08), Math.floor(height * 0.84), Math.floor(width * 0.92), Math.floor(height * 0.90), alertIndex); + return pixels; +} + +const width = 960; +const height = 540; +const palette = [ + color(248, 250, 252), + color(15, 23, 42), + color(219, 234, 254), + color(220, 252, 231), + color(185, 28, 28), + color(37, 99, 235), + color(180, 83, 9), + color(255, 255, 255) +].join(""); + +const gif = [ + "GIF89a", + u16(width), + u16(height), + "\xF2\x00\x00", + palette, + "\x21\xFF\x0BNETSCAPE2.0\x03\x01\x00\x00\x00", + frame(width, height, makeFrame(width, height, 4), 85, 8), + frame(width, height, makeFrame(width, height, 6), 85, 8), + frame(width, height, makeFrame(width, height, 5), 85, 8), + ";" +].join(""); + +writeFileSync(out, Buffer.from(gif, "binary")); +console.log(`wrote ${out}`); diff --git a/identity-recovery-risk-guard/src/identity-recovery-risk-guard.js b/identity-recovery-risk-guard/src/identity-recovery-risk-guard.js new file mode 100644 index 0000000..023ed37 --- /dev/null +++ b/identity-recovery-risk-guard/src/identity-recovery-risk-guard.js @@ -0,0 +1,431 @@ +import { createHash } from "node:crypto"; + +const HOUR_MS = 60 * 60 * 1000; +const SENSITIVE_LEVELS = new Set(["restricted", "regulated", "human-subjects"]); +const ELEVATED_ROLES = new Set(["owner", "admin"]); + +function stableHash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function asDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`); + return date; +} + +function hoursBetween(start, end) { + return (asDate(end).getTime() - asDate(start).getTime()) / HOUR_MS; +} + +function userById(input) { + return new Map((input.users ?? []).map((user) => [user.id, user])); +} + +function projectsForUser(input, userId) { + return (input.projects ?? []) + .map((project) => { + const membership = (project.memberships ?? []).find((item) => item.userId === userId); + const grants = (project.objectGrants ?? []).filter((grant) => grant.userId === userId); + if (!membership && grants.length === 0) return null; + return { + projectId: project.id, + title: project.title, + visibility: project.visibility, + sensitivity: project.sensitivity, + role: membership?.role ?? "object-grantee", + grants + }; + }) + .filter(Boolean); +} + +function sessionsForRequest(input, request) { + return (input.sessions ?? []).filter((session) => session.userId === request.userId && !session.revokedAt); +} + +function recentEventsForRequest(input, request) { + const windowHours = input.policy?.recentWindowHours ?? 72; + return (input.authEvents ?? []).filter((event) => { + if (event.userId !== request.userId) return false; + const delta = hoursBetween(event.at, request.requestedAt); + return delta >= 0 && delta <= windowHours; + }); +} + +function linkedIdentity(user, provider) { + return (user.linkedIdentities ?? []).find((identity) => identity.provider === provider); +} + +function domainOf(subject = "") { + const match = subject.toLowerCase().match(/@([^@]+)$/); + return match?.[1] ?? ""; +} + +function trustedDomain(input, domain) { + return (input.policy?.trustedInstitutionDomains ?? []).includes(domain); +} + +function severityFromPoints(points) { + if (points >= 80) return "critical"; + if (points >= 55) return "high"; + if (points >= 30) return "medium"; + return "low"; +} + +function addFactor(factors, code, points, message, evidence = {}) { + factors.push({ code, points, message, evidence }); +} + +function exposureSummary(input, request) { + const projects = projectsForUser(input, request.userId); + const elevatedProjects = projects.filter((project) => ELEVATED_ROLES.has(project.role)); + const sensitiveProjects = projects.filter((project) => SENSITIVE_LEVELS.has(project.sensitivity)); + const sensitiveGrants = projects.flatMap((project) => + project.grants + .filter((grant) => SENSITIVE_LEVELS.has(grant.sensitivity) || grant.action === "download") + .map((grant) => ({ ...grant, projectId: project.projectId, projectTitle: project.title })) + ); + + return { + projects, + elevatedProjects, + sensitiveProjects, + sensitiveGrants + }; +} + +function analyzeRequest(input, request, user) { + const sessions = sessionsForRequest(input, request); + const recentEvents = recentEventsForRequest(input, request); + const exposure = exposureSummary(input, request); + const factors = []; + const evidence = request.evidence ?? {}; + const source = request.source ?? {}; + const sourceMatchesTrustedSession = sessions.some( + (session) => session.trustedDevice && session.deviceFingerprint === source.deviceFingerprint && session.ipCountry === source.ipCountry + ); + const suspiciousSessions = sessions.filter( + (session) => !session.trustedDevice || session.deviceFingerprint !== source.deviceFingerprint || session.ipCountry !== source.ipCountry + ); + const failedLogins = recentEvents.filter((event) => event.type === "failed_login" && event.success === false); + + if (request.type === "mfa_reset" && user.mfa?.enrolled && !evidence.mfaBackupCode && !evidence.institutionalAdminApproved) { + addFactor(factors, "mfa_reset_without_strong_factor", 32, "MFA reset is missing backup-code or institutional-admin evidence", { + backupCode: Boolean(evidence.mfaBackupCode), + institutionalAdminApproved: Boolean(evidence.institutionalAdminApproved) + }); + } + + if ((request.type === "email_change" || request.type === "mfa_reset" || request.type === "password_reset") && !evidence.emailVerified) { + addFactor(factors, "email_not_verified", 18, "Recovery request has not verified the primary email channel"); + } + + if (!sourceMatchesTrustedSession && !evidence.deviceTrusted) { + addFactor(factors, "new_or_untrusted_device", 18, "Recovery request originated from a device that does not match a trusted session", { + sourceDevice: source.deviceFingerprint, + sourceCountry: source.ipCountry + }); + } + + if (suspiciousSessions.length > 0) { + addFactor(factors, "suspicious_active_sessions", 15, "Active sessions include a new device, lower assurance, or country mismatch", { + sessionIds: suspiciousSessions.map((session) => session.id).sort() + }); + } + + if (failedLogins.length >= 3) { + addFactor(factors, "failed_login_cluster", 16, "Recent failed-login cluster happened before the recovery request", { + count: failedLogins.length + }); + } + + if (request.type === "oauth_relink" || request.type === "saml_rebind") { + const existing = linkedIdentity(user, request.targetProvider); + const targetChanged = existing && existing.subject !== request.targetSubject; + if (targetChanged && !evidence.orcidReverified) { + addFactor(factors, "linked_identity_subject_changed", 21, "Linked identity subject changed without independent identity re-verification", { + provider: request.targetProvider, + previousSubject: existing.subject, + requestedSubject: request.targetSubject + }); + } + } + + if (request.type === "saml_rebind") { + const targetDomain = domainOf(request.targetSubject); + if (!trustedDomain(input, targetDomain) || targetDomain !== user.institutionDomain) { + addFactor(factors, "saml_domain_mismatch", 30, "SAML rebind target is outside the user's trusted institution domain", { + targetDomain, + expectedDomain: user.institutionDomain + }); + } + if (!evidence.institutionalAdminApproved) { + addFactor(factors, "missing_institution_admin_approval", 20, "SAML rebind requires institution-admin approval before access restoration"); + } + } + + if (exposure.elevatedProjects.length > 0) { + addFactor(factors, "elevated_project_role_exposure", 12, "User controls owner/admin roles that should be held during risky recovery", { + projectIds: exposure.elevatedProjects.map((project) => project.projectId).sort() + }); + } + + if (exposure.sensitiveProjects.length > 0 || exposure.sensitiveGrants.length > 0) { + addFactor(factors, "sensitive_research_access_exposure", 14, "User has restricted project or object access that raises recovery assurance requirements", { + projectIds: exposure.sensitiveProjects.map((project) => project.projectId).sort(), + objectIds: exposure.sensitiveGrants.map((grant) => grant.objectId).sort() + }); + } + + const riskScore = Math.min(100, factors.reduce((sum, factor) => sum + factor.points, 0)); + const severity = severityFromPoints(riskScore); + + return { + request, + user, + sessions, + recentEvents, + exposure, + factors, + riskScore, + severity + }; +} + +function decisionForAnalysis(input, analysis) { + const criticalThreshold = input.policy?.criticalRiskThreshold ?? 80; + const highThreshold = input.policy?.highRiskThreshold ?? 55; + const factorCodes = new Set(analysis.factors.map((factor) => factor.code)); + + if (analysis.riskScore >= criticalThreshold || factorCodes.has("saml_domain_mismatch")) { + return { + recovery: "hold_for_security_review", + projectAccess: "freeze_elevated_roles_and_sensitive_objects", + sessions: "revoke_untrusted_sessions", + payoutOrAttribution: "hold_profile_attribution_changes", + reasons: analysis.factors.map((factor) => factor.code) + }; + } + + if (analysis.riskScore >= highThreshold) { + return { + recovery: "require_institution_or_project_owner_approval", + projectAccess: "temporary_read_only", + sessions: "step_up_reauthentication", + payoutOrAttribution: "manual_review_before_changes", + reasons: analysis.factors.map((factor) => factor.code) + }; + } + + return { + recovery: "approve_with_monitoring", + projectAccess: "preserve_existing_access", + sessions: "keep_sessions_with_reauth_prompt", + payoutOrAttribution: "no_hold", + reasons: analysis.factors.map((factor) => factor.code) + }; +} + +function projectHoldsForAnalysis(input, analysis) { + const threshold = input.policy?.projectHoldRiskThreshold ?? 55; + if (analysis.riskScore < threshold) return []; + + return analysis.exposure.projects.map((project) => ({ + holdId: stableHash({ requestId: analysis.request.id, projectId: project.projectId, role: project.role }), + requestId: analysis.request.id, + userId: analysis.user.id, + projectId: project.projectId, + projectTitle: project.title, + role: project.role, + action: ELEVATED_ROLES.has(project.role) ? "freeze_role_changes" : "restrict_sensitive_objects", + heldObjectIds: project.grants + .filter((grant) => SENSITIVE_LEVELS.has(grant.sensitivity) || grant.action === "download") + .map((grant) => grant.objectId) + .sort(), + reason: "recovery_risk_before_access_restoration" + })); +} + +function sessionActionsForAnalysis(analysis) { + return analysis.sessions.map((session) => { + const source = analysis.request.source ?? {}; + const untrusted = !session.trustedDevice || session.deviceFingerprint !== source.deviceFingerprint || session.ipCountry !== source.ipCountry; + return { + actionId: stableHash({ requestId: analysis.request.id, sessionId: session.id }), + requestId: analysis.request.id, + sessionId: session.id, + action: analysis.severity === "critical" && untrusted ? "revoke" : analysis.severity === "low" ? "monitor" : "require_step_up", + reason: untrusted ? "session_does_not_match_recovery_source" : "session_matches_known_recovery_source" + }; + }); +} + +function requiredReviewers(analysis) { + const reviewers = new Set(); + const factorCodes = new Set(analysis.factors.map((factor) => factor.code)); + if (analysis.severity === "critical" || analysis.severity === "high") reviewers.add("security_reviewer"); + if (factorCodes.has("saml_domain_mismatch") || factorCodes.has("missing_institution_admin_approval")) reviewers.add("institution_admin"); + if (analysis.exposure.elevatedProjects.length > 0) reviewers.add("project_owner_delegate"); + if (analysis.exposure.sensitiveGrants.length > 0) reviewers.add("data_steward"); + return [...reviewers].sort(); +} + +function recoveryPacket(analysis, decision) { + const missingEvidence = []; + const evidence = analysis.request.evidence ?? {}; + + if (!evidence.emailVerified) missingEvidence.push("verified_email_channel"); + if (analysis.request.type === "mfa_reset" && !evidence.mfaBackupCode) missingEvidence.push("mfa_backup_code_or_admin_override"); + if (analysis.request.type === "saml_rebind" && !evidence.institutionalAdminApproved) missingEvidence.push("institution_admin_approval"); + if ((analysis.request.type === "oauth_relink" || analysis.request.type === "saml_rebind") && !evidence.orcidReverified) { + missingEvidence.push("independent_identity_reverification"); + } + + return { + packetId: stableHash({ requestId: analysis.request.id, decision }), + requestId: analysis.request.id, + userId: analysis.user.id, + userName: analysis.user.name, + riskScore: analysis.riskScore, + severity: analysis.severity, + decision: decision.recovery, + requiredReviewers: requiredReviewers(analysis), + missingEvidence: [...new Set(missingEvidence)].sort(), + safeNextSteps: decision.recovery === "approve_with_monitoring" + ? ["notify_user", "record_audit_event", "prompt_session_reauthentication"] + : ["freeze_sensitive_project_access", "collect_missing_evidence", "record_security_review"] + }; +} + +function findingFromFactor(analysis, factor) { + return { + id: stableHash({ requestId: analysis.request.id, code: factor.code, evidence: factor.evidence }), + requestId: analysis.request.id, + userId: analysis.user.id, + code: factor.code, + severity: severityFromPoints(factor.points + Math.floor(analysis.riskScore / 4)), + points: factor.points, + message: factor.message, + evidence: factor.evidence ?? {} + }; +} + +function auditEvents(analyses, decisions, packets, projectHolds, sessionActions) { + const events = [ + ...analyses.map((analysis) => ({ + type: "recovery_risk_scored", + requestId: analysis.request.id, + userId: analysis.user.id, + riskScore: analysis.riskScore, + severity: analysis.severity, + digest: stableHash({ requestId: analysis.request.id, factors: analysis.factors }) + })), + ...decisions.map((decision) => ({ + type: "recovery_decision_recorded", + requestId: decision.requestId, + recovery: decision.recovery, + projectAccess: decision.projectAccess, + digest: stableHash(decision) + })), + ...packets.map((packet) => ({ + type: "recovery_packet_created", + requestId: packet.requestId, + packetId: packet.packetId, + reviewers: packet.requiredReviewers, + digest: stableHash(packet) + })), + ...projectHolds.map((hold) => ({ + type: "project_access_hold_created", + requestId: hold.requestId, + projectId: hold.projectId, + action: hold.action, + digest: stableHash(hold) + })), + ...sessionActions + .filter((action) => action.action === "revoke" || action.action === "require_step_up") + .map((action) => ({ + type: "session_action_required", + requestId: action.requestId, + sessionId: action.sessionId, + action: action.action, + digest: stableHash(action) + })) + ]; + + return events.map((event) => ({ + ...event, + eventId: stableHash(event) + })); +} + +function severityRank(severity) { + return { critical: 0, high: 1, medium: 2, low: 3 }[severity] ?? 4; +} + +export function evaluateIdentityRecoveryRisk(input, options = {}) { + const generatedAt = options.generatedAt ?? input.generatedAt ?? new Date().toISOString(); + const users = userById(input); + const analyses = (input.recoveryRequests ?? []).map((request) => { + const user = users.get(request.userId); + if (!user) throw new Error(`Unknown user for recovery request: ${request.userId}`); + return analyzeRequest(input, request, user); + }); + + const decisions = analyses.map((analysis) => ({ + requestId: analysis.request.id, + userId: analysis.user.id, + ...decisionForAnalysis(input, analysis) + })); + const findings = analyses + .flatMap((analysis) => analysis.factors.map((factor) => findingFromFactor(analysis, factor))) + .sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || b.points - a.points || a.id.localeCompare(b.id)); + const projectHolds = analyses.flatMap((analysis) => projectHoldsForAnalysis(input, analysis)); + const sessionActions = analyses.flatMap((analysis) => sessionActionsForAnalysis(analysis)); + const packets = analyses.map((analysis) => recoveryPacket( + analysis, + decisions.find((decision) => decision.requestId === analysis.request.id) + )); + const events = auditEvents(analyses, decisions, packets, projectHolds, sessionActions); + + const report = { + generatedAt, + summary: { + requestsReviewed: analyses.length, + criticalRequests: analyses.filter((analysis) => analysis.severity === "critical").length, + highRequests: analyses.filter((analysis) => analysis.severity === "high").length, + projectHolds: projectHolds.length, + sessionRevocations: sessionActions.filter((action) => action.action === "revoke").length, + missingEvidenceItems: packets.reduce((sum, packet) => sum + packet.missingEvidence.length, 0) + }, + recoveryCases: analyses.map((analysis) => ({ + requestId: analysis.request.id, + userId: analysis.user.id, + userName: analysis.user.name, + type: analysis.request.type, + riskScore: analysis.riskScore, + severity: analysis.severity, + factors: analysis.factors.map((factor) => factor.code), + exposedProjectIds: analysis.exposure.projects.map((project) => project.projectId).sort() + })), + findings, + decisions, + projectHolds, + sessionActions, + recoveryPackets: packets, + auditEvents: events + }; + + return { + ...report, + evidenceDigest: stableHash({ + generatedAt, + summary: report.summary, + recoveryCases: report.recoveryCases, + decisions: report.decisions, + projectHolds: report.projectHolds, + sessionActions: report.sessionActions, + auditEvents: report.auditEvents.map((event) => event.eventId) + }) + }; +} diff --git a/identity-recovery-risk-guard/test/identity-recovery-risk-guard.test.js b/identity-recovery-risk-guard/test/identity-recovery-risk-guard.test.js new file mode 100644 index 0000000..7dd553d --- /dev/null +++ b/identity-recovery-risk-guard/test/identity-recovery-risk-guard.test.js @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { evaluateIdentityRecoveryRisk } from "../src/identity-recovery-risk-guard.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-recovery-cases.json"), "utf8")); + +describe("evaluateIdentityRecoveryRisk", () => { + it("holds high-risk MFA recovery and revokes untrusted sessions", () => { + const report = evaluateIdentityRecoveryRisk(sample); + const ada = report.recoveryCases.find((item) => item.requestId === "rec-ada-mfa-reset"); + const decision = report.decisions.find((item) => item.requestId === "rec-ada-mfa-reset"); + const sessionActions = report.sessionActions.filter((item) => item.requestId === "rec-ada-mfa-reset"); + + assert.equal(ada.severity, "critical"); + assert.equal(decision.recovery, "hold_for_security_review"); + assert.equal(decision.projectAccess, "freeze_elevated_roles_and_sensitive_objects"); + assert.equal(sessionActions.some((item) => item.sessionId === "sess-ada-known" && item.action === "revoke"), true); + assert.equal(report.projectHolds.some((hold) => hold.projectId === "project-quasar" && hold.action === "freeze_role_changes"), true); + }); + + it("requires institution approval for a SAML rebind outside the trusted domain", () => { + const report = evaluateIdentityRecoveryRisk(sample); + const lina = report.recoveryCases.find((item) => item.requestId === "rec-lina-saml-rebind"); + const packet = report.recoveryPackets.find((item) => item.requestId === "rec-lina-saml-rebind"); + const findingCodes = report.findings.filter((item) => item.requestId === "rec-lina-saml-rebind").map((item) => item.code); + + assert.equal(lina.severity, "high"); + assert.equal(findingCodes.includes("saml_domain_mismatch"), true); + assert.equal(packet.requiredReviewers.includes("institution_admin"), true); + assert.equal(packet.missingEvidence.includes("institution_admin_approval"), true); + }); + + it("approves a low-risk password reset with monitoring", () => { + const report = evaluateIdentityRecoveryRisk(sample); + const omar = report.recoveryCases.find((item) => item.requestId === "rec-omar-password"); + const decision = report.decisions.find((item) => item.requestId === "rec-omar-password"); + + assert.equal(omar.severity, "low"); + assert.equal(decision.recovery, "approve_with_monitoring"); + assert.equal(decision.projectAccess, "preserve_existing_access"); + }); + + it("generates deterministic audit evidence", () => { + const first = evaluateIdentityRecoveryRisk(sample); + const second = evaluateIdentityRecoveryRisk(sample); + + assert.equal(first.evidenceDigest, second.evidenceDigest); + assert.deepEqual(first.auditEvents.map((event) => event.eventId), second.auditEvents.map((event) => event.eventId)); + }); +});