diff --git a/anonymous-review-identity-escrow/README.md b/anonymous-review-identity-escrow/README.md new file mode 100644 index 0000000..d020490 --- /dev/null +++ b/anonymous-review-identity-escrow/README.md @@ -0,0 +1,34 @@ +# Anonymous Review Identity Escrow + +This is a self-contained module for SCIBASE issue #11, focused on the anonymous identity and privacy edge of User & Project Management. + +The module models a privacy-preserving escrow layer for optional anonymous peer review. It gives reviewers deterministic pseudonymous handles, keeps identity-provider evidence redacted from review subjects, evaluates object-level access requests, and supports policy-controlled deanonymization only through an audited break-glass flow. + +## What It Covers + +- Anonymous reviewer public handles that do not reveal real names. +- Linked identity evidence for ORCID, GitHub, and institutional SAML without exposing raw subjects. +- Identity assurance scoring for MFA, institution verification, verified providers, training, and risk flags. +- Object-level project access decisions for review comments, raw data, and identity exports. +- Deanonymization requests with policy-approved reasons, quorum, due-process notice, and evidence flags. +- Audit hashes for every access decision plus a packet-level digest. + +## Demo + +Run npm run check, npm test, and npm run demo from this directory. + +Expected demo output includes escrow-ready participants, allowed and denied grants, deanonymization approvals, and a deterministic audit digest. + +The reviewer-facing visual is in docs/demo.svg; a short demo video is in docs/demo.mp4. + +## Files + +- src/identity-escrow.js - core deterministic escrow, access, and deanonymization logic. +- data/sample-escrow.json - synthetic sample identities, project objects, access requests, and break-glass cases. +- test/identity-escrow.test.js - Node assert coverage for pseudonyms, redaction, access denial, break-glass controls, and audit hashes. +- scripts/demo.js - terminal demo for reviewers. +- docs/requirement-map.md - maps this module to issue #11 requirements. + +## Privacy Notes + +No real private identity record, credential, token, ORCID secret, SAML assertion, OAuth key, or external service call is used. All identities and project data are synthetic. diff --git a/anonymous-review-identity-escrow/data/sample-escrow.json b/anonymous-review-identity-escrow/data/sample-escrow.json new file mode 100644 index 0000000..002f291 --- /dev/null +++ b/anonymous-review-identity-escrow/data/sample-escrow.json @@ -0,0 +1,127 @@ +{ + "programId": "blind-review-cohort-q3", + "policy": { + "anonymousMode": true, + "requiredAssurance": 70, + "deanonymizationQuorum": 2, + "allowedDeanonymizationReasons": [ + "harassment", + "data-exfiltration", + "research-integrity-investigation" + ], + "restrictedScopesForAnonymousReviewers": [ + "raw-data-download", + "identity-export", + "billing-admin" + ] + }, + "participants": [ + { + "id": "reviewer-ada", + "displayName": "Ada Chen", + "role": "reviewer", + "profileMode": "anonymous-review", + "linkedIdentities": [ + { "provider": "orcid", "verified": true, "subject": "0000-0002-1825-0097" }, + { "provider": "github", "verified": true, "subject": "ada-lab" }, + { "provider": "saml", "verified": true, "subject": "university.example" } + ], + "mfaEnabled": true, + "institutionVerified": true, + "training": { + "humanSubjects": "valid", + "dataUse": "valid" + }, + "flags": [] + }, + { + "id": "reviewer-bo", + "displayName": "Bo Patel", + "role": "reviewer", + "profileMode": "anonymous-review", + "linkedIdentities": [ + { "provider": "orcid", "verified": true, "subject": "0000-0003-1111-2222" }, + { "provider": "github", "verified": false, "subject": "bo-lab" } + ], + "mfaEnabled": false, + "institutionVerified": false, + "training": { + "humanSubjects": "valid", + "dataUse": "expired" + }, + "flags": ["mfa-missing", "data-use-training-expired"] + }, + { + "id": "admin-mira", + "displayName": "Mira Santos", + "role": "admin", + "profileMode": "private", + "linkedIdentities": [ + { "provider": "saml", "verified": true, "subject": "research-office.example" }, + { "provider": "orcid", "verified": true, "subject": "0000-0001-5555-7777" } + ], + "mfaEnabled": true, + "institutionVerified": true, + "training": { + "humanSubjects": "valid", + "dataUse": "valid" + }, + "flags": [] + } + ], + "projects": [ + { + "id": "project-neuro-crispr", + "visibility": "invitation-only", + "sensitiveScopes": ["raw-data-download", "identity-export"], + "objects": [ + { "id": "manuscript-draft", "kind": "document", "scope": "review-comment" }, + { "id": "supplemental-raw-cells", "kind": "dataset", "scope": "raw-data-download" }, + { "id": "author-roster", "kind": "identity", "scope": "identity-export" } + ] + } + ], + "accessRequests": [ + { + "id": "grant-1", + "participantId": "reviewer-ada", + "projectId": "project-neuro-crispr", + "objectId": "manuscript-draft", + "requestedScope": "review-comment" + }, + { + "id": "grant-2", + "participantId": "reviewer-ada", + "projectId": "project-neuro-crispr", + "objectId": "supplemental-raw-cells", + "requestedScope": "raw-data-download" + }, + { + "id": "grant-3", + "participantId": "reviewer-bo", + "projectId": "project-neuro-crispr", + "objectId": "manuscript-draft", + "requestedScope": "review-comment" + } + ], + "deanonymizationRequests": [ + { + "id": "case-1", + "requesterId": "admin-mira", + "targetParticipantId": "reviewer-bo", + "reason": "research-integrity-investigation", + "approvals": ["admin-mira", "ethics-chair"], + "dueProcessNotice": true, + "evidenceFlags": ["data-use-training-expired", "mfa-missing"] + }, + { + "id": "case-2", + "requesterId": "admin-mira", + "targetParticipantId": "reviewer-ada", + "reason": "curiosity", + "approvals": ["admin-mira"], + "dueProcessNotice": false, + "evidenceFlags": [] + } + ] +} diff --git a/anonymous-review-identity-escrow/docs/demo.mp4 b/anonymous-review-identity-escrow/docs/demo.mp4 new file mode 100644 index 0000000..e47a088 Binary files /dev/null and b/anonymous-review-identity-escrow/docs/demo.mp4 differ diff --git a/anonymous-review-identity-escrow/docs/demo.svg b/anonymous-review-identity-escrow/docs/demo.svg new file mode 100644 index 0000000..89a3915 --- /dev/null +++ b/anonymous-review-identity-escrow/docs/demo.svg @@ -0,0 +1,26 @@ + + Anonymous review identity escrow demo + A dashboard-style summary showing escrow-ready reviewers, access decisions, and deanonymization controls. + + + Anonymous Review Identity Escrow + Pseudonymous reviewers, redacted identity evidence, access policy, and audited break-glass release. + + Escrow-ready + 2 / 3 + + Allowed grants + 1 + + Denied grants + 2 + + Break-glass releases + 1 + + Reviewer anon-23dabcdd5e + ORCID, GitHub, and SAML verified. Real subjects are redacted from review subjects. + Access denied: raw-data-download + Anonymous reviewers can comment on manuscripts, but cannot export raw data or identities. + Audit digest: deterministic SHA-256 packet hash for project-level audit logs. + diff --git a/anonymous-review-identity-escrow/docs/requirement-map.md b/anonymous-review-identity-escrow/docs/requirement-map.md new file mode 100644 index 0000000..514f3b9 --- /dev/null +++ b/anonymous-review-identity-escrow/docs/requirement-map.md @@ -0,0 +1,19 @@ +# Requirement Map + +This module targets SCIBASE-AI/SCIBASE.AI issue #11, "User & Project Management". + +| Issue requirement | Implementation evidence | +| --- | --- | +| Optional anonymous user mode for open peer review or public browsing | src/identity-escrow.js issues deterministic pseudonymous handles for profileMode: anonymous-review and hides real display names from review subjects. | +| Account linking across ORCID, GitHub, and institutional identity | data/sample-escrow.json models linked identity providers; scoreIdentityAssurance scores verified providers without exposing subjects. | +| Public vs private profile modes | Escrow records include profileMode and identityVisibleToReviewSubject decisions. | +| Role-based and object-level access control | evaluateAccessRequest decides project/object grants by role, requested scope, assurance score, training, and anonymous-review restrictions. | +| External collaborator and reviewer governance | Access requests model reviewer grants for invitation-only projects and restricted scientific objects. | +| Project-level audit log | Every access and deanonymization decision includes an auditEventHash; the packet includes an overall auditDigest. | +| Meaningful attribution without unsafe identity exposure | Public handles, redacted identity evidence, sealed identity hashes, and break-glass policy separate credit/trust signals from real identity release. | + +## Non-goals + +- No live OAuth, SAML, ORCID, payment, email, or external API integration. +- No real private identity records are included. +- No credential, secret, or production data is required. diff --git a/anonymous-review-identity-escrow/package.json b/anonymous-review-identity-escrow/package.json new file mode 100644 index 0000000..7cbfad6 --- /dev/null +++ b/anonymous-review-identity-escrow/package.json @@ -0,0 +1,12 @@ +{ + "name": "anonymous-review-identity-escrow", + "version": "1.0.0", + "private": true, + "description": "Dependency-free anonymous review identity escrow and access audit module for SCIBASE user/project management.", + "type": "commonjs", + "scripts": { + "check": "node --check src/identity-escrow.js && node --check scripts/demo.js && node --check test/identity-escrow.test.js", + "test": "node test/identity-escrow.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/anonymous-review-identity-escrow/scripts/demo.js b/anonymous-review-identity-escrow/scripts/demo.js new file mode 100644 index 0000000..84d603a --- /dev/null +++ b/anonymous-review-identity-escrow/scripts/demo.js @@ -0,0 +1,18 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { createIdentityEscrowPacket } = require("../src/identity-escrow"); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "../data/sample-escrow.json"), "utf8") +); +const packet = createIdentityEscrowPacket(fixture); + +console.log("Program: " + packet.programId); +console.log("Escrow-ready participants: " + packet.summary.escrowReady + "/" + packet.summary.participants); +console.log("Allowed access grants: " + packet.summary.allowedAccess); +console.log("Denied access grants: " + packet.summary.deniedAccess); +console.log("Deanonymization approved: " + packet.summary.deanonymizationApproved); +console.log("Top action: " + packet.actions[0].message); +console.log("Audit digest: " + packet.auditDigest); diff --git a/anonymous-review-identity-escrow/src/identity-escrow.js b/anonymous-review-identity-escrow/src/identity-escrow.js new file mode 100644 index 0000000..63162b7 --- /dev/null +++ b/anonymous-review-identity-escrow/src/identity-escrow.js @@ -0,0 +1,265 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const REDACTED = "[redacted]"; + +function stableStringify(value) { + if (Array.isArray(value)) { + return "[" + value.map(stableStringify).join(",") + "]"; + } + if (value && typeof value === "object") { + return "{" + Object.keys(value) + .sort() + .map((key) => JSON.stringify(key) + ":" + stableStringify(value[key])) + .join(",") + "}"; + } + return JSON.stringify(value); +} + +function sha256(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function validateEscrowInput(input) { + const issues = []; + if (!input || typeof input !== "object") issues.push("input"); + if (!input.programId) issues.push("programId"); + if (!input.policy) issues.push("policy"); + if (!Array.isArray(input.participants)) issues.push("participants"); + if (!Array.isArray(input.projects)) issues.push("projects"); + if (!Array.isArray(input.accessRequests)) issues.push("accessRequests"); + if (!Array.isArray(input.deanonymizationRequests)) issues.push("deanonymizationRequests"); + + for (const participant of input.participants || []) { + if (!participant.id) issues.push("participant.id"); + if (!participant.role) issues.push((participant.id || "unknown") + ".role"); + if (!Array.isArray(participant.linkedIdentities)) { + issues.push((participant.id || "unknown") + ".linkedIdentities"); + } + } + + for (const request of input.accessRequests || []) { + if (!request.participantId) issues.push((request.id || "unknown") + ".participantId"); + if (!request.projectId) issues.push((request.id || "unknown") + ".projectId"); + if (!request.requestedScope) issues.push((request.id || "unknown") + ".requestedScope"); + } + + if (issues.length) { + throw new Error("Invalid escrow input: " + issues.join(", ")); + } +} + +function indexById(items) { + return new Map(items.map((item) => [item.id, item])); +} + +function pseudonymFor(programId, participantId) { + return "anon-" + sha256({ programId, participantId }).slice(0, 10); +} + +function scoreIdentityAssurance(participant) { + const verifiedLinks = participant.linkedIdentities.filter((identity) => identity.verified); + const providers = new Set(verifiedLinks.map((identity) => identity.provider)); + let score = 0; + score += Math.min(45, verifiedLinks.length * 15); + score += providers.has("orcid") ? 15 : 0; + score += providers.has("saml") ? 15 : 0; + score += participant.mfaEnabled ? 15 : 0; + score += participant.institutionVerified ? 10 : 0; + score -= (participant.flags || []).length * 8; + return Math.max(0, Math.min(100, score)); +} + +function summarizeIdentityEvidence(participant) { + return { + verifiedProviders: participant.linkedIdentities + .filter((identity) => identity.verified) + .map((identity) => identity.provider) + .sort(), + redactedSubjects: participant.linkedIdentities.map((identity) => ({ + provider: identity.provider, + verified: identity.verified, + subject: REDACTED + })), + mfaEnabled: participant.mfaEnabled, + institutionVerified: participant.institutionVerified, + training: participant.training || {}, + flags: participant.flags || [] + }; +} + +function escrowParticipants(input) { + return input.participants.map((participant) => { + const anonymous = participant.profileMode === "anonymous-review"; + const assuranceScore = scoreIdentityAssurance(participant); + return { + participantId: participant.id, + role: participant.role, + publicHandle: anonymous ? pseudonymFor(input.programId, participant.id) : participant.displayName, + profileMode: participant.profileMode, + identityVisibleToReviewSubject: !anonymous, + assuranceScore, + identityEvidence: summarizeIdentityEvidence(participant), + status: assuranceScore >= input.policy.requiredAssurance ? "escrow-ready" : "needs-identity-hardening" + }; + }); +} + +function evaluateAccessRequest(request, context) { + const participant = context.participants.get(request.participantId); + const project = context.projects.get(request.projectId); + const escrow = context.escrowByParticipant.get(request.participantId); + const reasons = []; + + if (!participant) reasons.push("unknown-participant"); + if (!project) reasons.push("unknown-project"); + if (!escrow) reasons.push("missing-escrow-record"); + if (participant && participant.role !== "admin" && participant.role !== "reviewer") { + reasons.push("role-not-eligible"); + } + if (escrow && escrow.assuranceScore < context.policy.requiredAssurance) { + reasons.push("identity-assurance-below-threshold"); + } + if ( + participant && + participant.profileMode === "anonymous-review" && + context.policy.restrictedScopesForAnonymousReviewers.includes(request.requestedScope) + ) { + reasons.push("anonymous-reviewer-restricted-scope"); + } + if (participant && participant.training && participant.training.dataUse === "expired") { + reasons.push("data-use-training-expired"); + } + + return { + id: request.id, + participantId: request.participantId, + publicHandle: escrow ? escrow.publicHandle : "unknown", + projectId: request.projectId, + objectId: request.objectId, + requestedScope: request.requestedScope, + decision: reasons.length === 0 ? "allow" : "deny", + reasons: reasons.length ? reasons : ["least-privilege-review-access"], + auditEventHash: sha256({ + request, + reasons, + publicHandle: escrow ? escrow.publicHandle : "unknown" + }) + }; +} + +function evaluateDeanonymizationRequest(request, context) { + const requester = context.participants.get(request.requesterId); + const target = context.participants.get(request.targetParticipantId); + const targetEscrow = context.escrowByParticipant.get(request.targetParticipantId); + const reasons = []; + + if (!requester || requester.role !== "admin") reasons.push("requester-not-admin"); + if (!target) reasons.push("unknown-target"); + if (!context.policy.allowedDeanonymizationReasons.includes(request.reason)) { + reasons.push("reason-not-policy-approved"); + } + if ((request.approvals || []).length < context.policy.deanonymizationQuorum) { + reasons.push("quorum-not-met"); + } + if (!request.dueProcessNotice) reasons.push("due-process-notice-missing"); + if (!request.evidenceFlags || request.evidenceFlags.length === 0) { + reasons.push("no-evidence-flags"); + } + + return { + id: request.id, + targetPublicHandle: targetEscrow ? targetEscrow.publicHandle : "unknown", + reason: request.reason, + decision: reasons.length === 0 ? "release-to-authorized-admins" : "keep-sealed", + reasons: reasons.length ? reasons : ["policy-reason-approved", "quorum-met", "due-process-recorded"], + sealedIdentityHash: target ? sha256({ targetParticipantId: target.id, identities: target.linkedIdentities }) : null, + auditEventHash: sha256(request) + }; +} + +function buildActionItems(accessDecisions, deanonymizationDecisions, escrowRecords) { + const actions = []; + for (const record of escrowRecords) { + if (record.status !== "escrow-ready") { + actions.push({ + owner: record.publicHandle, + type: "identity-hardening", + message: "Require MFA, institution verification, or current training before anonymous review access." + }); + } + } + for (const decision of accessDecisions) { + if (decision.decision === "deny") { + actions.push({ + owner: decision.publicHandle, + type: "access-remediation", + message: "Denied " + decision.requestedScope + ": " + decision.reasons.join(", ") + "." + }); + } + } + for (const decision of deanonymizationDecisions) { + if (decision.decision === "release-to-authorized-admins") { + actions.push({ + owner: decision.targetPublicHandle, + type: "privacy-breakglass", + message: "Deanonymization approved only for authorized admins with audit trail." + }); + } + } + return actions; +} + +function createIdentityEscrowPacket(input) { + validateEscrowInput(input); + + const escrowRecords = escrowParticipants(input); + const context = { + policy: input.policy, + participants: indexById(input.participants), + projects: indexById(input.projects), + escrowByParticipant: indexById(escrowRecords.map((record) => ({ + ...record, + id: record.participantId + }))) + }; + const accessDecisions = input.accessRequests.map((request) => evaluateAccessRequest(request, context)); + const deanonymizationDecisions = input.deanonymizationRequests.map((request) => + evaluateDeanonymizationRequest(request, context) + ); + const actions = buildActionItems(accessDecisions, deanonymizationDecisions, escrowRecords); + const packet = { + programId: input.programId, + generatedAt: "2026-05-15T10:20:00.000Z", + policy: input.policy, + escrowRecords, + accessDecisions, + deanonymizationDecisions, + actions, + summary: { + participants: escrowRecords.length, + escrowReady: escrowRecords.filter((record) => record.status === "escrow-ready").length, + allowedAccess: accessDecisions.filter((decision) => decision.decision === "allow").length, + deniedAccess: accessDecisions.filter((decision) => decision.decision === "deny").length, + deanonymizationApproved: deanonymizationDecisions.filter((decision) => + decision.decision === "release-to-authorized-admins" + ).length + } + }; + return { + ...packet, + auditDigest: sha256(packet) + }; +} + +module.exports = { + REDACTED, + createIdentityEscrowPacket, + evaluateAccessRequest, + evaluateDeanonymizationRequest, + pseudonymFor, + scoreIdentityAssurance, + stableStringify, + validateEscrowInput +}; diff --git a/anonymous-review-identity-escrow/test/identity-escrow.test.js b/anonymous-review-identity-escrow/test/identity-escrow.test.js new file mode 100644 index 0000000..04289e2 --- /dev/null +++ b/anonymous-review-identity-escrow/test/identity-escrow.test.js @@ -0,0 +1,62 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + REDACTED, + createIdentityEscrowPacket, + pseudonymFor, + scoreIdentityAssurance, + validateEscrowInput +} = require("../src/identity-escrow"); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "../data/sample-escrow.json"), "utf8") +); + +validateEscrowInput(fixture); + +const packet = createIdentityEscrowPacket(fixture); + +assert.equal(packet.programId, "blind-review-cohort-q3"); +assert.equal(packet.summary.participants, 3); +assert.equal(packet.summary.escrowReady, 2); +assert.equal(packet.summary.allowedAccess, 1); +assert.equal(packet.summary.deniedAccess, 2); +assert.equal(packet.summary.deanonymizationApproved, 1); +assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); + +const ada = fixture.participants.find((participant) => participant.id === "reviewer-ada"); +const bo = fixture.participants.find((participant) => participant.id === "reviewer-bo"); +assert.equal(scoreIdentityAssurance(ada), 100); +assert.equal(scoreIdentityAssurance(bo), 14); + +const adaEscrow = packet.escrowRecords.find((record) => record.participantId === "reviewer-ada"); +assert.equal(adaEscrow.publicHandle, pseudonymFor(fixture.programId, "reviewer-ada")); +assert.notEqual(adaEscrow.publicHandle, ada.displayName); +assert.equal(adaEscrow.identityVisibleToReviewSubject, false); +assert.equal(adaEscrow.identityEvidence.redactedSubjects[0].subject, REDACTED); + +const rawDataDecision = packet.accessDecisions.find((decision) => decision.id === "grant-2"); +assert.equal(rawDataDecision.decision, "deny"); +assert.ok(rawDataDecision.reasons.includes("anonymous-reviewer-restricted-scope")); + +const weakReviewerDecision = packet.accessDecisions.find((decision) => decision.id === "grant-3"); +assert.equal(weakReviewerDecision.decision, "deny"); +assert.ok(weakReviewerDecision.reasons.includes("identity-assurance-below-threshold")); +assert.ok(weakReviewerDecision.reasons.includes("data-use-training-expired")); + +const approvedBreakglass = packet.deanonymizationDecisions.find((decision) => decision.id === "case-1"); +assert.equal(approvedBreakglass.decision, "release-to-authorized-admins"); +assert.match(approvedBreakglass.sealedIdentityHash, /^[a-f0-9]{64}$/); + +const rejectedBreakglass = packet.deanonymizationDecisions.find((decision) => decision.id === "case-2"); +assert.equal(rejectedBreakglass.decision, "keep-sealed"); +assert.ok(rejectedBreakglass.reasons.includes("reason-not-policy-approved")); +assert.ok(rejectedBreakglass.reasons.includes("quorum-not-met")); + +assert.ok(packet.actions.some((action) => action.type === "identity-hardening")); +assert.ok(packet.actions.some((action) => action.type === "privacy-breakglass")); + +console.log("anonymous-review-identity-escrow tests passed");