api-ape 3.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +59 -572
  2. package/client/README.md +73 -14
  3. package/client/auth/crypto/aead.js +214 -0
  4. package/client/auth/crypto/constants.js +32 -0
  5. package/client/auth/crypto/encoding.js +104 -0
  6. package/client/auth/crypto/files.md +27 -0
  7. package/client/auth/crypto/kdf.js +217 -0
  8. package/client/auth/crypto-utils.js +118 -0
  9. package/client/auth/files.md +52 -0
  10. package/client/auth/key-recovery.js +288 -0
  11. package/client/auth/recovery/constants.js +37 -0
  12. package/client/auth/recovery/files.md +23 -0
  13. package/client/auth/recovery/key-derivation.js +61 -0
  14. package/client/auth/recovery/sss-browser.js +189 -0
  15. package/client/auth/share-storage.js +205 -0
  16. package/client/auth/storage/constants.js +18 -0
  17. package/client/auth/storage/db.js +132 -0
  18. package/client/auth/storage/files.md +27 -0
  19. package/client/auth/storage/keys.js +173 -0
  20. package/client/auth/storage/shares.js +200 -0
  21. package/client/browser.js +190 -23
  22. package/client/connectSocket.js +418 -988
  23. package/client/connection/README.md +23 -0
  24. package/client/connection/fileDownload.js +256 -0
  25. package/client/connection/fileHandling.js +450 -0
  26. package/client/connection/fileUtils.js +346 -0
  27. package/client/connection/files.md +71 -0
  28. package/client/connection/messageHandler.js +105 -0
  29. package/client/connection/network.js +350 -0
  30. package/client/connection/proxy.js +233 -0
  31. package/client/connection/sender.js +333 -0
  32. package/client/connection/state.js +321 -0
  33. package/client/connection/subscriptions.js +151 -0
  34. package/client/files.md +53 -0
  35. package/client/index.js +298 -142
  36. package/client/transports/README.md +50 -0
  37. package/client/transports/files.md +41 -0
  38. package/client/transports/streamParser.js +195 -0
  39. package/client/transports/streaming.js +555 -203
  40. package/dist/ape.js +6 -1
  41. package/dist/ape.js.map +4 -4
  42. package/index.d.ts +38 -16
  43. package/package.json +31 -6
  44. package/server/README.md +272 -67
  45. package/server/adapters/README.md +23 -14
  46. package/server/adapters/files.md +68 -0
  47. package/server/adapters/firebase.js +543 -160
  48. package/server/adapters/index.js +362 -112
  49. package/server/adapters/mongo.js +530 -140
  50. package/server/adapters/postgres.js +534 -155
  51. package/server/adapters/redis.js +508 -143
  52. package/server/adapters/supabase.js +555 -186
  53. package/server/client/README.md +43 -0
  54. package/server/client/connection.js +586 -0
  55. package/server/client/files.md +40 -0
  56. package/server/client/index.js +342 -0
  57. package/server/files.md +54 -0
  58. package/server/index.js +322 -71
  59. package/server/lib/README.md +26 -0
  60. package/server/lib/broadcast/clients.js +219 -0
  61. package/server/lib/broadcast/files.md +58 -0
  62. package/server/lib/broadcast/index.js +57 -0
  63. package/server/lib/broadcast/publishProxy.js +110 -0
  64. package/server/lib/broadcast/pubsub.js +137 -0
  65. package/server/lib/broadcast/sendProxy.js +103 -0
  66. package/server/lib/bun.js +315 -99
  67. package/server/lib/fileTransfer/README.md +63 -0
  68. package/server/lib/fileTransfer/files.md +30 -0
  69. package/server/lib/fileTransfer/streaming.js +435 -0
  70. package/server/lib/fileTransfer.js +710 -326
  71. package/server/lib/files.md +111 -0
  72. package/server/lib/httpUtils.js +283 -0
  73. package/server/lib/loader.js +208 -7
  74. package/server/lib/longPolling/README.md +63 -0
  75. package/server/lib/longPolling/files.md +44 -0
  76. package/server/lib/longPolling/getHandler.js +365 -0
  77. package/server/lib/longPolling/postHandler.js +327 -0
  78. package/server/lib/longPolling.js +174 -219
  79. package/server/lib/main.js +369 -532
  80. package/server/lib/runtimes/README.md +42 -0
  81. package/server/lib/runtimes/bun.js +586 -0
  82. package/server/lib/runtimes/files.md +56 -0
  83. package/server/lib/runtimes/node.js +511 -0
  84. package/server/lib/wiring.js +539 -98
  85. package/server/lib/ws/README.md +35 -0
  86. package/server/lib/ws/adapters/README.md +54 -0
  87. package/server/lib/ws/adapters/bun.js +538 -170
  88. package/server/lib/ws/adapters/deno.js +623 -149
  89. package/server/lib/ws/adapters/files.md +42 -0
  90. package/server/lib/ws/files.md +74 -0
  91. package/server/lib/ws/frames.js +532 -154
  92. package/server/lib/ws/index.js +207 -10
  93. package/server/lib/ws/server.js +385 -92
  94. package/server/lib/ws/socket.js +549 -181
  95. package/server/lib/wsProvider.js +363 -89
  96. package/server/plugins/binary.js +282 -0
  97. package/server/security/README.md +92 -0
  98. package/server/security/auth/README.md +319 -0
  99. package/server/security/auth/adapters/files.md +95 -0
  100. package/server/security/auth/adapters/ldap/constants.js +37 -0
  101. package/server/security/auth/adapters/ldap/files.md +19 -0
  102. package/server/security/auth/adapters/ldap/helpers.js +111 -0
  103. package/server/security/auth/adapters/ldap.js +353 -0
  104. package/server/security/auth/adapters/oauth2/constants.js +41 -0
  105. package/server/security/auth/adapters/oauth2/files.md +19 -0
  106. package/server/security/auth/adapters/oauth2/helpers.js +123 -0
  107. package/server/security/auth/adapters/oauth2.js +273 -0
  108. package/server/security/auth/adapters/opaque-handlers.js +314 -0
  109. package/server/security/auth/adapters/opaque.js +205 -0
  110. package/server/security/auth/adapters/saml/constants.js +52 -0
  111. package/server/security/auth/adapters/saml/files.md +19 -0
  112. package/server/security/auth/adapters/saml/helpers.js +74 -0
  113. package/server/security/auth/adapters/saml.js +173 -0
  114. package/server/security/auth/adapters/totp.js +703 -0
  115. package/server/security/auth/adapters/webauthn.js +625 -0
  116. package/server/security/auth/files.md +61 -0
  117. package/server/security/auth/framework/constants.js +27 -0
  118. package/server/security/auth/framework/files.md +23 -0
  119. package/server/security/auth/framework/handlers.js +272 -0
  120. package/server/security/auth/framework/socket-auth.js +177 -0
  121. package/server/security/auth/handlers/auth-messages.js +143 -0
  122. package/server/security/auth/handlers/files.md +28 -0
  123. package/server/security/auth/index.js +290 -0
  124. package/server/security/auth/mfa/crypto/aead.js +148 -0
  125. package/server/security/auth/mfa/crypto/constants.js +35 -0
  126. package/server/security/auth/mfa/crypto/files.md +27 -0
  127. package/server/security/auth/mfa/crypto/kdf.js +120 -0
  128. package/server/security/auth/mfa/crypto/utils.js +68 -0
  129. package/server/security/auth/mfa/crypto-utils.js +80 -0
  130. package/server/security/auth/mfa/files.md +77 -0
  131. package/server/security/auth/mfa/ledger/constants.js +75 -0
  132. package/server/security/auth/mfa/ledger/errors.js +73 -0
  133. package/server/security/auth/mfa/ledger/files.md +23 -0
  134. package/server/security/auth/mfa/ledger/share-record.js +32 -0
  135. package/server/security/auth/mfa/ledger.js +255 -0
  136. package/server/security/auth/mfa/recovery/constants.js +67 -0
  137. package/server/security/auth/mfa/recovery/files.md +19 -0
  138. package/server/security/auth/mfa/recovery/handlers.js +216 -0
  139. package/server/security/auth/mfa/recovery.js +191 -0
  140. package/server/security/auth/mfa/sss/constants.js +21 -0
  141. package/server/security/auth/mfa/sss/files.md +23 -0
  142. package/server/security/auth/mfa/sss/gf256.js +103 -0
  143. package/server/security/auth/mfa/sss/serialization.js +82 -0
  144. package/server/security/auth/mfa/sss.js +161 -0
  145. package/server/security/auth/mfa/two-of-three/constants.js +58 -0
  146. package/server/security/auth/mfa/two-of-three/files.md +23 -0
  147. package/server/security/auth/mfa/two-of-three/handlers.js +241 -0
  148. package/server/security/auth/mfa/two-of-three/helpers.js +71 -0
  149. package/server/security/auth/mfa/two-of-three.js +136 -0
  150. package/server/security/auth/nonce-manager.js +89 -0
  151. package/server/security/auth/state-machine-mfa.js +269 -0
  152. package/server/security/auth/state-machine.js +257 -0
  153. package/server/security/extractRootDomain.js +144 -16
  154. package/server/security/files.md +51 -0
  155. package/server/security/origin.js +197 -15
  156. package/server/security/reply.js +274 -16
  157. package/server/socket/README.md +119 -0
  158. package/server/socket/authMiddleware.js +299 -0
  159. package/server/socket/files.md +86 -0
  160. package/server/socket/open.js +154 -8
  161. package/server/socket/pluginHooks.js +334 -0
  162. package/server/socket/receive.js +184 -224
  163. package/server/socket/receiveContext.js +117 -0
  164. package/server/socket/send.js +416 -78
  165. package/server/socket/tagUtils.js +402 -0
  166. package/server/utils/README.md +19 -0
  167. package/server/utils/deepRequire.js +255 -30
  168. package/server/utils/files.md +57 -0
  169. package/server/utils/genId.js +182 -20
  170. package/server/utils/parseUserAgent.js +313 -251
  171. package/server/utils/userAgent/README.md +65 -0
  172. package/server/utils/userAgent/files.md +46 -0
  173. package/server/utils/userAgent/patterns.js +545 -0
  174. package/utils/README.md +21 -0
  175. package/utils/files.md +66 -0
  176. package/utils/jss/README.md +21 -0
  177. package/utils/jss/decode.js +471 -0
  178. package/utils/jss/encode.js +312 -0
  179. package/utils/jss/files.md +68 -0
  180. package/utils/jss/plugins.js +210 -0
  181. package/utils/jss.js +219 -273
  182. package/utils/messageHash.js +238 -35
  183. package/dist/api-ape.min.js +0 -2
  184. package/dist/api-ape.min.js.map +0 -7
  185. package/server/client.js +0 -311
  186. package/server/lib/broadcast.js +0 -146
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @fileoverview Shamir Secret Sharing (SSS) Utilities
3
+ *
4
+ * Implements threshold secret sharing using GF(256) arithmetic.
5
+ *
6
+ * @module server/security/auth/mfa/sss
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const crypto = require("crypto");
12
+ const { SSSError } = require("./sss/constants");
13
+ const { gfMul, gfDiv, gfAdd, evaluatePolynomial, lagrangeInterpolate } = require("./sss/gf256");
14
+ const { serializeShare, deserializeShare, verifyShareFormat } = require("./sss/serialization");
15
+
16
+ /**
17
+ * Split a secret into n shares with threshold k
18
+ *
19
+ * @param {Buffer|Uint8Array|string} secret - Secret to split
20
+ * @param {number} threshold - Minimum shares needed to reconstruct
21
+ * @param {number} totalShares - Total shares to generate
22
+ * @returns {Array} Array of shares with index and data
23
+ * @throws {Error} If parameters are invalid
24
+ */
25
+ function split(secret, threshold, totalShares) {
26
+ if (threshold < 2) {
27
+ const err = new Error(`Threshold must be at least 2, got ${threshold}`);
28
+ err.code = SSSError.INVALID_THRESHOLD;
29
+ throw err;
30
+ }
31
+ if (totalShares < threshold) {
32
+ const err = new Error(`Total shares (${totalShares}) must be >= threshold (${threshold})`);
33
+ err.code = SSSError.INVALID_SHARE_COUNT;
34
+ throw err;
35
+ }
36
+ if (totalShares > 255) {
37
+ const err = new Error(`Total shares (${totalShares}) must be <= 255 for GF(256)`);
38
+ err.code = SSSError.INVALID_SHARE_COUNT;
39
+ throw err;
40
+ }
41
+
42
+ let secretBuffer;
43
+ if (typeof secret === "string") secretBuffer = Buffer.from(secret, "utf8");
44
+ else if (secret instanceof Uint8Array) secretBuffer = Buffer.from(secret);
45
+ else if (Buffer.isBuffer(secret)) secretBuffer = secret;
46
+ else {
47
+ const err = new Error("Secret must be a Buffer, Uint8Array, or string");
48
+ err.code = SSSError.INVALID_SECRET;
49
+ throw err;
50
+ }
51
+
52
+ if (secretBuffer.length === 0) {
53
+ const err = new Error("Secret cannot be empty");
54
+ err.code = SSSError.INVALID_SECRET;
55
+ throw err;
56
+ }
57
+
58
+ const coeffCount = threshold - 1;
59
+ const randomBytes = crypto.randomBytes(secretBuffer.length * coeffCount);
60
+
61
+ const shares = [];
62
+ for (let i = 0; i < totalShares; i++) {
63
+ shares.push({ index: i + 1, data: Buffer.alloc(secretBuffer.length) });
64
+ }
65
+
66
+ for (let byteIdx = 0; byteIdx < secretBuffer.length; byteIdx++) {
67
+ const coefficients = new Uint8Array(threshold);
68
+ coefficients[0] = secretBuffer[byteIdx];
69
+ for (let c = 1; c < threshold; c++) {
70
+ coefficients[c] = randomBytes[byteIdx * coeffCount + (c - 1)];
71
+ }
72
+ for (let shareIdx = 0; shareIdx < totalShares; shareIdx++) {
73
+ shares[shareIdx].data[byteIdx] = evaluatePolynomial(coefficients, shares[shareIdx].index);
74
+ }
75
+ }
76
+ return shares;
77
+ }
78
+
79
+ /**
80
+ * Combine shares to reconstruct the original secret
81
+ *
82
+ * @param {Array} shares - Array of share objects
83
+ * @returns {Buffer} Reconstructed secret
84
+ * @throws {Error} If shares are insufficient or malformed
85
+ */
86
+ function combine(shares) {
87
+ if (!Array.isArray(shares) || shares.length === 0) {
88
+ const err = new Error("Shares must be a non-empty array");
89
+ err.code = SSSError.INSUFFICIENT_SHARES;
90
+ throw err;
91
+ }
92
+ if (shares.length < 2) {
93
+ const err = new Error("At least 2 shares are required");
94
+ err.code = SSSError.INSUFFICIENT_SHARES;
95
+ throw err;
96
+ }
97
+
98
+ const secretLength = shares[0].data?.length;
99
+ if (!secretLength) {
100
+ const err = new Error("Invalid share format: missing data");
101
+ err.code = SSSError.INVALID_SHARE_FORMAT;
102
+ throw err;
103
+ }
104
+
105
+ const seenIndices = new Set();
106
+ for (const share of shares) {
107
+ if (typeof share.index !== "number" || share.index < 1 || share.index > 255) {
108
+ const err = new Error(`Invalid share index: ${share.index}. Must be 1-255`);
109
+ err.code = SSSError.INVALID_SHARE_FORMAT;
110
+ throw err;
111
+ }
112
+ if (!Buffer.isBuffer(share.data) && !(share.data instanceof Uint8Array)) {
113
+ const err = new Error("Share data must be a Buffer or Uint8Array");
114
+ err.code = SSSError.INVALID_SHARE_FORMAT;
115
+ throw err;
116
+ }
117
+ if (share.data.length !== secretLength) {
118
+ const err = new Error(`Share data length mismatch: expected ${secretLength}, got ${share.data.length}`);
119
+ err.code = SSSError.SHARE_INDEX_MISMATCH;
120
+ throw err;
121
+ }
122
+ if (seenIndices.has(share.index)) {
123
+ const err = new Error(`Duplicate share index: ${share.index}`);
124
+ err.code = SSSError.DUPLICATE_SHARE_INDEX;
125
+ throw err;
126
+ }
127
+ seenIndices.add(share.index);
128
+ }
129
+
130
+ const secret = Buffer.alloc(secretLength);
131
+ for (let byteIdx = 0; byteIdx < secretLength; byteIdx++) {
132
+ const points = shares.map((share) => ({ x: share.index, y: share.data[byteIdx] }));
133
+ secret[byteIdx] = lagrangeInterpolate(points);
134
+ }
135
+ return secret;
136
+ }
137
+
138
+ /**
139
+ * Generate a new random secret
140
+ *
141
+ * @param {number} length - Length in bytes (default: 32)
142
+ * @returns {Buffer} Random secret
143
+ */
144
+ function generateSecret(length = 32) {
145
+ return crypto.randomBytes(length);
146
+ }
147
+
148
+ module.exports = {
149
+ split,
150
+ combine,
151
+ serializeShare,
152
+ deserializeShare,
153
+ verifyShareFormat,
154
+ generateSecret,
155
+ SSSError,
156
+ _gfMul: gfMul,
157
+ _gfDiv: gfDiv,
158
+ _gfAdd: gfAdd,
159
+ _evaluatePolynomial: evaluatePolynomial,
160
+ _lagrangeInterpolate: lagrangeInterpolate,
161
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file Two-of-three message types, errors, and default configuration
3
+ */
4
+ "use strict";
5
+
6
+ /**
7
+ * Message types for two-of-three protocol
8
+ * @enum {string}
9
+ */
10
+ const TwoOfThreeMessageType = {
11
+ ENROLLMENT_START: "key_recovery_enrollment_start",
12
+ ENROLLMENT_CHALLENGE: "key_recovery_enrollment_challenge",
13
+ ENROLLMENT_FINISH: "key_recovery_enrollment_finish",
14
+ ENROLLMENT_OK: "key_recovery_enrollment_ok",
15
+ ENROLLMENT_FAIL: "key_recovery_enrollment_fail",
16
+ RECOVERY_START: "key_recovery_start",
17
+ RECOVERY_SHARES: "key_recovery_shares",
18
+ RECOVERY_COMPLETE: "key_recovery_complete",
19
+ RECOVERY_OK: "key_recovery_ok",
20
+ RECOVERY_FAIL: "key_recovery_fail",
21
+ ROTATION_START: "key_recovery_rotation_start",
22
+ ROTATION_OK: "key_recovery_rotation_ok",
23
+ ROTATION_FAIL: "key_recovery_rotation_fail",
24
+ };
25
+
26
+ /**
27
+ * Error codes for two-of-three operations
28
+ * @enum {string}
29
+ */
30
+ const TwoOfThreeError = {
31
+ INSUFFICIENT_FACTORS: "INSUFFICIENT_FACTORS",
32
+ INVALID_FACTOR: "INVALID_FACTOR",
33
+ SHARE_DECRYPTION_FAILED: "SHARE_DECRYPTION_FAILED",
34
+ RECONSTRUCTION_FAILED: "RECONSTRUCTION_FAILED",
35
+ NOT_ENROLLED: "NOT_ENROLLED",
36
+ ALREADY_ENROLLED: "ALREADY_ENROLLED",
37
+ ENROLLMENT_EXPIRED: "ENROLLMENT_EXPIRED",
38
+ INVALID_FLOW: "INVALID_FLOW",
39
+ REVOKED_SHARE: "REVOKED_SHARE",
40
+ INVALID_PROOF: "INVALID_PROOF",
41
+ PENDING_ENROLLMENT: "PENDING_ENROLLMENT",
42
+ };
43
+
44
+ /**
45
+ * Default configuration
46
+ */
47
+ const DEFAULT_CONFIG = {
48
+ requiredFactors: 2,
49
+ allowedFlows: ["oauth+totp", "oauth+webauthn", "webauthn+totp"],
50
+ enrollmentTimeout: 300000,
51
+ secretLength: 32,
52
+ };
53
+
54
+ module.exports = {
55
+ TwoOfThreeMessageType,
56
+ TwoOfThreeError,
57
+ DEFAULT_CONFIG,
58
+ };
@@ -0,0 +1,23 @@
1
+ # Two-of-Three Module
2
+
3
+ 2-of-3 key recovery adapter components.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ two-of-three/
9
+ ├── constants.js - Message types, error codes, default config
10
+ ├── handlers.js - Enrollment and recovery handler factories
11
+ └── helpers.js - Cleanup and utility functions
12
+ ```
13
+
14
+ ## Files
15
+
16
+ ### `constants.js`
17
+ Defines TwoOfThreeMessageType, TwoOfThreeError, and DEFAULT_CONFIG.
18
+
19
+ ### `handlers.js`
20
+ Factory functions for enrollment start/finish, recovery start/complete, and rotation handlers.
21
+
22
+ ### `helpers.js`
23
+ Pending state cleanup and expiry management utilities.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file Two-of-three enrollment, recovery, and rotation handlers
3
+ */
4
+ "use strict";
5
+
6
+ const crypto = require("crypto");
7
+ const { serializeShare, generateSecret } = require("../sss");
8
+ const { generateSalt } = require("../crypto-utils");
9
+ const { ShareId, FactorType } = require("../ledger");
10
+ const { TwoOfThreeMessageType, TwoOfThreeError } = require("./constants");
11
+ const { generateChallenge, computeProofHash, getEnrollmentKey } = require("./helpers");
12
+
13
+ /**
14
+ * Create enrollment handlers
15
+ * @param {Object} ctx - Context with ledger, maps, and config
16
+ * @returns {Object} Enrollment handler functions
17
+ */
18
+ function createEnrollmentHandlers(ctx) {
19
+ const { ledger, pendingEnrollments, enrollmentTimeout, secretLength, cleanupExpired } = ctx;
20
+
21
+ /**
22
+ * Handle enrollment start
23
+ * @param {Object} params - Parameters
24
+ * @param {string} params.clientId - Client ID
25
+ * @param {string} params.userId - User ID
26
+ * @returns {Promise<Object>} Enrollment challenge
27
+ */
28
+ async function handleEnrollmentStart({ clientId, userId }) {
29
+ cleanupExpired();
30
+
31
+ if (await ledger.isEnrolled(userId)) {
32
+ const err = new Error(`User ${userId} is already enrolled`);
33
+ err.code = TwoOfThreeError.ALREADY_ENROLLED;
34
+ throw err;
35
+ }
36
+
37
+ const key = getEnrollmentKey(clientId, userId);
38
+ if (pendingEnrollments.has(key)) {
39
+ const err = new Error("Enrollment already in progress");
40
+ err.code = TwoOfThreeError.PENDING_ENROLLMENT;
41
+ throw err;
42
+ }
43
+
44
+ const kUser = generateSecret(secretLength);
45
+ const shares = require("../sss").split(kUser, 2, 3);
46
+ const challenge = generateChallenge();
47
+ const s3Salt = generateSalt(16);
48
+
49
+ pendingEnrollments.set(key, { userId, challenge, kUser, shares, s3Salt, expiresAt: Date.now() + enrollmentTimeout });
50
+ setTimeout(() => pendingEnrollments.delete(key), enrollmentTimeout + 1000);
51
+
52
+ return {
53
+ type: TwoOfThreeMessageType.ENROLLMENT_CHALLENGE,
54
+ challenge,
55
+ s3Salt: s3Salt.toString("base64"),
56
+ factorRequirements: {
57
+ S1: { factor: "oauth", description: "OAuth/OPAQUE authentication" },
58
+ S2: { factor: "webauthn", description: "WebAuthn credential" },
59
+ S3: { factor: "totp", description: "TOTP authenticator" },
60
+ },
61
+ shares: { S1: serializeShare(shares[0]), S2: serializeShare(shares[1]), S3: serializeShare(shares[2]) },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Handle enrollment finish
67
+ * @param {Object} params - Parameters
68
+ * @param {string} params.clientId - Client ID
69
+ * @param {string} params.userId - User ID
70
+ * @param {Object} params.encryptedShares - Encrypted shares
71
+ * @returns {Promise<Object>} Enrollment result
72
+ */
73
+ async function handleEnrollmentFinish({ clientId, userId, encryptedShares }) {
74
+ cleanupExpired();
75
+
76
+ const key = getEnrollmentKey(clientId, userId);
77
+ const pending = pendingEnrollments.get(key);
78
+
79
+ if (!pending) {
80
+ const err = new Error("No pending enrollment found or enrollment expired");
81
+ err.code = TwoOfThreeError.ENROLLMENT_EXPIRED;
82
+ throw err;
83
+ }
84
+
85
+ if (pending.expiresAt < Date.now()) {
86
+ pendingEnrollments.delete(key);
87
+ const err = new Error("Enrollment has expired");
88
+ err.code = TwoOfThreeError.ENROLLMENT_EXPIRED;
89
+ throw err;
90
+ }
91
+
92
+ if (!encryptedShares?.S1 || !encryptedShares?.S3) {
93
+ const err = new Error("Missing encrypted shares (S1 and S3 required)");
94
+ err.code = TwoOfThreeError.INVALID_FACTOR;
95
+ throw err;
96
+ }
97
+
98
+ const proofHash = computeProofHash(pending.kUser);
99
+
100
+ await ledger.storeShares(userId, {
101
+ [ShareId.S1]: { factor: FactorType.OAUTH, data: Buffer.from(encryptedShares.S1, "base64") },
102
+ [ShareId.S3]: { factor: FactorType.TOTP, data: Buffer.from(encryptedShares.S3, "base64") },
103
+ }, { proofHash });
104
+
105
+ pendingEnrollments.delete(key);
106
+
107
+ return {
108
+ type: TwoOfThreeMessageType.ENROLLMENT_OK,
109
+ userId,
110
+ shares: {
111
+ S1: { stored: true, factor: FactorType.OAUTH },
112
+ S2: { stored: false, factor: FactorType.WEBAUTHN, note: "Client-stored" },
113
+ S3: { stored: true, factor: FactorType.TOTP },
114
+ },
115
+ };
116
+ }
117
+
118
+ return { handleEnrollmentStart, handleEnrollmentFinish };
119
+ }
120
+
121
+ /**
122
+ * Create recovery handlers
123
+ * @param {Object} ctx - Context with ledger, maps, and config
124
+ * @returns {Object} Recovery handler functions
125
+ */
126
+ function createRecoveryHandlers(ctx) {
127
+ const { ledger, pendingRecoveries, enrollmentTimeout, cleanupExpired } = ctx;
128
+
129
+ /**
130
+ * Handle recovery start
131
+ * @param {Object} params - Parameters
132
+ * @param {string} params.clientId - Client ID
133
+ * @param {string} params.userId - User ID
134
+ * @returns {Promise<Object>} Recovery shares and challenge
135
+ */
136
+ async function handleRecoveryStart({ clientId, userId }) {
137
+ cleanupExpired();
138
+
139
+ if (!(await ledger.isEnrolled(userId))) {
140
+ const err = new Error(`User ${userId} is not enrolled in key recovery`);
141
+ err.code = TwoOfThreeError.NOT_ENROLLED;
142
+ throw err;
143
+ }
144
+
145
+ const { shares, metadata } = await ledger.fetchShares(userId, [ShareId.S1, ShareId.S3]);
146
+ const challenge = generateChallenge();
147
+ const key = getEnrollmentKey(clientId, userId);
148
+ pendingRecoveries.set(key, { userId, challenge, expiresAt: Date.now() + enrollmentTimeout });
149
+
150
+ return {
151
+ type: TwoOfThreeMessageType.RECOVERY_SHARES,
152
+ challenge,
153
+ encShares: { S1: shares[ShareId.S1]?.toString("base64") || null, S3: shares[ShareId.S3]?.toString("base64") || null },
154
+ metadata: { S1: metadata[ShareId.S1], S2: metadata[ShareId.S2], S3: metadata[ShareId.S3] },
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Handle recovery complete
160
+ * @param {Object} params - Parameters
161
+ * @param {string} params.clientId - Client ID
162
+ * @param {string} params.userId - User ID
163
+ * @param {string} params.proof - Recovery proof
164
+ * @returns {Promise<Object>} Recovery result
165
+ */
166
+ async function handleRecoveryComplete({ clientId, userId, proof }) {
167
+ cleanupExpired();
168
+
169
+ const key = getEnrollmentKey(clientId, userId);
170
+ const pending = pendingRecoveries.get(key);
171
+
172
+ if (!pending) {
173
+ const err = new Error("No pending recovery found");
174
+ err.code = TwoOfThreeError.INVALID_FLOW;
175
+ throw err;
176
+ }
177
+
178
+ if (proof) {
179
+ const storedProofHash = await ledger.getProofHash(userId);
180
+ if (storedProofHash) {
181
+ const expectedProof = crypto.createHmac("sha256", storedProofHash).update(pending.challenge).digest("base64");
182
+ if (proof !== expectedProof) {
183
+ const err = new Error("Invalid recovery proof");
184
+ err.code = TwoOfThreeError.INVALID_PROOF;
185
+ throw err;
186
+ }
187
+ }
188
+ }
189
+
190
+ pendingRecoveries.delete(key);
191
+ return { type: TwoOfThreeMessageType.RECOVERY_OK, userId, tier: 3 };
192
+ }
193
+
194
+ return { handleRecoveryStart, handleRecoveryComplete };
195
+ }
196
+
197
+ /**
198
+ * Create rotation handler
199
+ * @param {Object} ctx - Context with ledger
200
+ * @returns {Object} Rotation handler function
201
+ */
202
+ function createRotationHandler(ctx) {
203
+ const { ledger } = ctx;
204
+
205
+ /**
206
+ * Handle share rotation
207
+ * @param {Object} params - Parameters
208
+ * @param {string} params.userId - User ID
209
+ * @param {string} params.shareId - Share ID
210
+ * @param {string} params.encryptedShare - Encrypted share data
211
+ * @param {string} params.reason - Rotation reason
212
+ * @returns {Promise<Object>} Rotation result
213
+ */
214
+ async function handleRotation({ userId, shareId, encryptedShare, reason = "rotation" }) {
215
+ if (!Object.values(ShareId).includes(shareId)) {
216
+ const err = new Error(`Invalid share ID: ${shareId}`);
217
+ err.code = TwoOfThreeError.INVALID_FACTOR;
218
+ throw err;
219
+ }
220
+
221
+ if (!(await ledger.isEnrolled(userId))) {
222
+ const err = new Error(`User ${userId} is not enrolled`);
223
+ err.code = TwoOfThreeError.NOT_ENROLLED;
224
+ throw err;
225
+ }
226
+
227
+ const metadata = await ledger.getShareMetadata(userId, shareId);
228
+
229
+ if (shareId === ShareId.S2) {
230
+ const result = await ledger.updateS2Metadata(userId, metadata.version + 1);
231
+ return { type: TwoOfThreeMessageType.ROTATION_OK, shareId, oldVersion: result.oldVersion, newVersion: result.newVersion };
232
+ }
233
+
234
+ const result = await ledger.rotateShare(userId, shareId, { factor: metadata.factor, data: Buffer.from(encryptedShare, "base64") }, reason);
235
+ return { type: TwoOfThreeMessageType.ROTATION_OK, shareId, oldVersion: result.oldVersion, newVersion: result.newVersion };
236
+ }
237
+
238
+ return { handleRotation };
239
+ }
240
+
241
+ module.exports = { createEnrollmentHandlers, createRecoveryHandlers, createRotationHandler };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @file Two-of-three helper functions
3
+ */
4
+ "use strict";
5
+
6
+ const crypto = require("crypto");
7
+ const { timingSafeEqual } = require("../crypto-utils");
8
+
9
+ /**
10
+ * Generate challenge nonce
11
+ * @returns {string} Base64url challenge
12
+ */
13
+ function generateChallenge() {
14
+ return crypto.randomBytes(32).toString("base64url");
15
+ }
16
+
17
+ /**
18
+ * Compute proof hash for K_user verification
19
+ * @param {Buffer} kUser - The user's key
20
+ * @returns {Buffer} SHA-256 hash of K_user
21
+ */
22
+ function computeProofHash(kUser) {
23
+ return crypto.createHash("sha256").update(kUser).digest();
24
+ }
25
+
26
+ /**
27
+ * Verify proof of K_user possession
28
+ * @param {Buffer} kUser - Claimed K_user
29
+ * @param {Buffer} storedProofHash - Stored proof hash
30
+ * @returns {boolean} True if proof is valid
31
+ */
32
+ function verifyProof(kUser, storedProofHash) {
33
+ const computedHash = computeProofHash(kUser);
34
+ return timingSafeEqual(computedHash, storedProofHash);
35
+ }
36
+
37
+ /**
38
+ * Get enrollment key for pending operations
39
+ * @param {string} clientId - Client identifier
40
+ * @param {string} userId - User identifier
41
+ * @returns {string} Combined key
42
+ */
43
+ function getEnrollmentKey(clientId, userId) {
44
+ return `${clientId}:${userId}`;
45
+ }
46
+
47
+ /**
48
+ * Create a pending operation cleanup function
49
+ * @param {Map} pendingEnrollments - Pending enrollments map
50
+ * @param {Map} pendingRecoveries - Pending recoveries map
51
+ * @returns {Function} Cleanup function
52
+ */
53
+ function createCleanupExpired(pendingEnrollments, pendingRecoveries) {
54
+ return function cleanupExpired() {
55
+ const now = Date.now();
56
+ for (const [key, pending] of pendingEnrollments) {
57
+ if (pending.expiresAt < now) pendingEnrollments.delete(key);
58
+ }
59
+ for (const [key, pending] of pendingRecoveries) {
60
+ if (pending.expiresAt < now) pendingRecoveries.delete(key);
61
+ }
62
+ };
63
+ }
64
+
65
+ module.exports = {
66
+ generateChallenge,
67
+ computeProofHash,
68
+ verifyProof,
69
+ getEnrollmentKey,
70
+ createCleanupExpired,
71
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @fileoverview Two-of-Three Authentication Adapter
3
+ *
4
+ * Implements 2-of-3 SSS key recovery for Tier 3 (HIGH_SECURITY).
5
+ * Compatible with Passport.js strategy interface.
6
+ *
7
+ * @module server/security/auth/mfa/two-of-three
8
+ */
9
+
10
+ "use strict";
11
+
12
+ const { createLedger } = require("./ledger");
13
+ const { TwoOfThreeMessageType, TwoOfThreeError, DEFAULT_CONFIG } = require("./two-of-three/constants");
14
+ const { createCleanupExpired } = require("./two-of-three/helpers");
15
+ const { createEnrollmentHandlers, createRecoveryHandlers, createRotationHandler } = require("./two-of-three/handlers");
16
+
17
+ /**
18
+ * Create a Two-of-Three Strategy
19
+ *
20
+ * @param {Object|Function} options - Config or verify callback
21
+ * @param {Function} verify - Verify callback (Passport.js style)
22
+ * @returns {Object} Two-of-three adapter/strategy
23
+ */
24
+ function createTwoOfThreeStrategy(options, verify) {
25
+ let config = {};
26
+ let verifyCallback = null;
27
+
28
+ if (typeof options === "function") {
29
+ verifyCallback = options;
30
+ } else {
31
+ config = options || {};
32
+ verifyCallback = verify;
33
+ }
34
+
35
+ const {
36
+ requiredFactors = DEFAULT_CONFIG.requiredFactors,
37
+ allowedFlows = DEFAULT_CONFIG.allowedFlows,
38
+ enrollmentTimeout = DEFAULT_CONFIG.enrollmentTimeout,
39
+ secretLength = DEFAULT_CONFIG.secretLength,
40
+ passReqToCallback = false,
41
+ getRecord, saveRecord, deleteRecord,
42
+ auditEnabled = true, onAuditEvent,
43
+ } = config;
44
+
45
+ const ledger = createLedger({ getRecord, saveRecord, deleteRecord, auditEnabled, onAuditEvent });
46
+ const pendingEnrollments = new Map();
47
+ const pendingRecoveries = new Map();
48
+ const cleanupExpired = createCleanupExpired(pendingEnrollments, pendingRecoveries);
49
+
50
+ const ctx = { ledger, pendingEnrollments, pendingRecoveries, enrollmentTimeout, secretLength, cleanupExpired };
51
+ const { handleEnrollmentStart, handleEnrollmentFinish } = createEnrollmentHandlers(ctx);
52
+ const { handleRecoveryStart, handleRecoveryComplete } = createRecoveryHandlers(ctx);
53
+ const { handleRotation } = createRotationHandler(ctx);
54
+
55
+ /**
56
+ * Passport.js authenticate method
57
+ * @param {Object} req - Request object with recovery data
58
+ * @param {Object} authOptions - Authentication options
59
+ */
60
+ function authenticate(req, authOptions = {}) {
61
+ const self = this;
62
+ const { userId, factors, proof } = req.body || req;
63
+
64
+ if (!factors || Object.keys(factors).length < requiredFactors) {
65
+ return self.fail({ message: `At least ${requiredFactors} factors required`, code: TwoOfThreeError.INSUFFICIENT_FACTORS });
66
+ }
67
+
68
+ const factorKeys = Object.keys(factors).sort().join("+");
69
+ const normalizedFlow = factorKeys.toLowerCase();
70
+ if (!allowedFlows.some((f) => f === normalizedFlow || f.split("+").sort().join("+") === normalizedFlow)) {
71
+ return self.fail({ message: `Flow ${normalizedFlow} not allowed`, code: TwoOfThreeError.INVALID_FLOW });
72
+ }
73
+
74
+ if (verifyCallback) {
75
+ /**
76
+ * Passport.js verify callback handler
77
+ * @param {Error|null} err - Error if verification failed
78
+ * @param {Object|false} user - User object if verified, false if not
79
+ * @param {Object} info - Additional info about verification
80
+ * @returns {void}
81
+ */
82
+ const verified = (err, user, info) => {
83
+ if (err) return self.error(err);
84
+ if (!user) return self.fail(info);
85
+ return self.success(user, info);
86
+ };
87
+
88
+ if (passReqToCallback) return verifyCallback(req, { userId, factors, proof }, verified);
89
+ return verifyCallback({ userId, factors, proof }, verified);
90
+ }
91
+
92
+ self.success({ userId, tier: 3 }, { factors: Object.keys(factors) });
93
+ }
94
+
95
+ /**
96
+ * Clean up pending operations for a client
97
+ * @param {string} clientId - Client ID
98
+ * @returns {void}
99
+ */
100
+ function cleanupClient(clientId) {
101
+ for (const key of pendingEnrollments.keys()) {
102
+ if (key.startsWith(`${clientId}:`)) pendingEnrollments.delete(key);
103
+ }
104
+ for (const key of pendingRecoveries.keys()) {
105
+ if (key.startsWith(`${clientId}:`)) pendingRecoveries.delete(key);
106
+ }
107
+ }
108
+
109
+ return {
110
+ name: "two-of-three",
111
+ authenticate,
112
+ type: "two-of-three",
113
+ tier: 3,
114
+ MessageType: TwoOfThreeMessageType,
115
+ Error: TwoOfThreeError,
116
+ handleEnrollmentStart,
117
+ handleEnrollmentFinish,
118
+ handleRecoveryStart,
119
+ handleRecoveryComplete,
120
+ handleRotation,
121
+ isEnrolled: (userId) => ledger.isEnrolled(userId),
122
+ cleanupClient,
123
+ getShareVersions: (userId) => ledger.getVersions(userId),
124
+ ledger,
125
+ _pendingEnrollments: pendingEnrollments,
126
+ _pendingRecoveries: pendingRecoveries,
127
+ };
128
+ }
129
+
130
+ module.exports = {
131
+ createTwoOfThreeStrategy,
132
+ Strategy: createTwoOfThreeStrategy,
133
+ TwoOfThreeMessageType,
134
+ TwoOfThreeError,
135
+ DEFAULT_CONFIG,
136
+ };