@nacre.work/api 0.18.0 → 0.19.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.
package/dist/server.js CHANGED
@@ -564,7 +564,15 @@ function handledTooBusy(res, error, instance, requestId) {
564
564
  return true;
565
565
  }
566
566
  async function handleAuth(req, res, instance, requestId, options) {
567
- if (options.login === undefined || req.method !== 'POST') {
567
+ // Every route here produces a credential and therefore takes a body, with
568
+ // exactly one exception: `/v1/auth/methods` is a **read**, and the contract
569
+ // has said `get` since it was written. A blanket `!== 'POST'` refused it, so
570
+ // the endpoint whose whole job is telling a sign-in screen what exists
571
+ // answered 404 — and the console, reading `password_reset` off a problem
572
+ // document, hid the recovery link on every deployment including the ones
573
+ // with a relay configured. The feature did the opposite of its purpose.
574
+ const reads = instance === '/v1/auth/methods';
575
+ if (options.login === undefined || req.method !== (reads ? 'GET' : 'POST')) {
568
576
  const problem = notFound(instance, requestId);
569
577
  send(res, problem.status, problem.toJSON(), requestId);
570
578
  return;
@@ -701,7 +709,14 @@ async function handleAuth(req, res, instance, requestId, options) {
701
709
  * would learn by pressing the link anyway. It says nothing about any address.
702
710
  */
703
711
  if (instance === '/v1/auth/methods') {
704
- send(res, 200, { password_reset: options.recovery !== undefined }, requestId);
712
+ send(res, 200, {
713
+ password_reset: options.recovery !== undefined,
714
+ // Which second factors this installation can *challenge* with. A
715
+ // sign-in screen that offered "use your security key" where the
716
+ // deployment has no relying party would be the same defect the
717
+ // recovery link was: a control the server refuses.
718
+ second_factor_kinds: options.secondFactors?.kinds ?? [],
719
+ }, requestId);
705
720
  return;
706
721
  }
707
722
  /*
@@ -784,10 +799,41 @@ async function handleAuth(req, res, instance, requestId, options) {
784
799
  * survives a Redis restart; this one is the bound that costs an attacker
785
800
  * their source.
786
801
  */
802
+ /*
803
+ * The options a browser needs before it can produce an assertion.
804
+ *
805
+ * It takes the sign-in challenge and nothing else, so this is not a route
806
+ * that will tell a stranger which authenticators an address holds: the
807
+ * challenge is a JWT this server signed one password ago. Not rate limited
808
+ * separately, because reaching it costs a correct password.
809
+ */
810
+ if (instance === '/v1/auth/second-factor/webauthn') {
811
+ const challenge = body?.challenge;
812
+ if (typeof challenge !== 'string') {
813
+ const problem = badRequest(instance, requestId, "'challenge' is required.");
814
+ send(res, problem.status, problem.toJSON(), requestId);
815
+ return;
816
+ }
817
+ const begun = await options.login.beginSecondFactorWebAuthn(challenge);
818
+ if (begun === undefined) {
819
+ // One refusal for a challenge that is not ours, one that has expired, and
820
+ // a person holding no key — the same rule the rest of this path follows.
821
+ refuse();
822
+ return;
823
+ }
824
+ send(res, 200, assertionOptionsJson(begun), requestId);
825
+ return;
826
+ }
787
827
  if (instance === '/v1/auth/second-factor') {
788
- const { challenge, code } = (body ?? {});
789
- if (typeof challenge !== 'string' || typeof code !== 'string') {
790
- const problem = badRequest(instance, requestId, "'challenge' and 'code' are required.");
828
+ const { challenge, code, assertion } = (body ?? {});
829
+ if (typeof challenge !== 'string') {
830
+ const problem = badRequest(instance, requestId, "'challenge' is required.");
831
+ send(res, problem.status, problem.toJSON(), requestId);
832
+ return;
833
+ }
834
+ const proof = readProof(code, assertion);
835
+ if (proof === undefined) {
836
+ const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required.");
791
837
  send(res, problem.status, problem.toJSON(), requestId);
792
838
  return;
793
839
  }
@@ -809,7 +855,7 @@ async function handleAuth(req, res, instance, requestId, options) {
809
855
  }
810
856
  }
811
857
  }
812
- const tokens = await options.login.completeSecondFactor(challenge, code);
858
+ const tokens = await options.login.completeSecondFactor(challenge, proof);
813
859
  if (tokens === undefined) {
814
860
  // One refusal for an expired challenge, a forged one, a wrong code and a
815
861
  // disabled account alike. Which of the four it was is nothing a client
@@ -825,8 +871,10 @@ async function handleAuth(req, res, instance, requestId, options) {
825
871
  result: 'allow',
826
872
  target: { user_id: tokens.userId },
827
873
  // The journal says which door, because "signed in with a second factor"
828
- // and "signed in with a password" are different facts to an investigator.
829
- detail: { second_factor: true },
874
+ // and "signed in with a password" are different facts to an investigator
875
+ // — and so are the two kinds of second factor, since only one of them is
876
+ // proof against a page that looked like this one.
877
+ detail: { second_factor: true, second_factor_kind: proof.kind },
830
878
  requestId,
831
879
  });
832
880
  send(res, 200, tokenJson(tokens), requestId);
@@ -857,6 +905,71 @@ async function handleAuth(req, res, instance, requestId, options) {
857
905
  const problem = notFound(instance, requestId);
858
906
  send(res, problem.status, problem.toJSON(), requestId);
859
907
  }
908
+ /**
909
+ * A WebAuthn assertion out of a request body, or nothing.
910
+ *
911
+ * One parser, because two routes take one: the second half of a sign-in and
912
+ * taking a factor off an account. Two would be two ideas of which fields are
913
+ * required and which encoding they arrive in, and the half that got it wrong
914
+ * would refuse every authenticator with a message about neither.
915
+ *
916
+ * base64url throughout, which is what a browser reports and what
917
+ * `webauthn_challenges.challenge` stores — so the comparison downstream is a
918
+ * string equality and never an encoding round trip.
919
+ */
920
+ function readAssertion(value) {
921
+ if (typeof value !== 'object' || value === null)
922
+ return undefined;
923
+ const it = value;
924
+ const fields = ['credential_id', 'authenticator_data', 'client_data_json', 'signature', 'challenge'];
925
+ if (fields.some((f) => typeof it[f] !== 'string' || it[f] === ''))
926
+ return undefined;
927
+ const bytes = (name) => new Uint8Array(Buffer.from(it[name], 'base64url'));
928
+ return {
929
+ credentialId: it.credential_id,
930
+ authenticatorData: bytes('authenticator_data'),
931
+ clientDataJSON: bytes('client_data_json'),
932
+ signature: bytes('signature'),
933
+ challenge: it.challenge,
934
+ };
935
+ }
936
+ /**
937
+ * Which of the two proofs a body carries.
938
+ *
939
+ * Both present is a refusal rather than a precedence. A caller sending each is
940
+ * a caller with two ideas of how it is signing in, and resolving that by
941
+ * preferring one leaves the other apparently offered and ignored — which is
942
+ * the argument `loadConfig` makes about a secret and a key ref together.
943
+ */
944
+ function readProof(code, assertion) {
945
+ const hasCode = typeof code === 'string' && code !== '';
946
+ if (hasCode && assertion !== undefined)
947
+ return undefined;
948
+ if (hasCode)
949
+ return { kind: 'code', code: code };
950
+ const response = readAssertion(assertion);
951
+ return response === undefined ? undefined : { kind: 'webauthn', response };
952
+ }
953
+ /** The wire shape of what a browser needs for `navigator.credentials.get`. */
954
+ function assertionOptionsJson(options) {
955
+ return {
956
+ challenge: options.challenge,
957
+ rp_id: options.rpId,
958
+ allow_credentials: options.allowCredentials,
959
+ timeout_ms: options.timeoutMs,
960
+ };
961
+ }
962
+ /** And for `navigator.credentials.create`. */
963
+ function registrationOptionsJson(options) {
964
+ return {
965
+ challenge: options.challenge,
966
+ rp: { id: options.rp.id, name: options.rp.name },
967
+ user: { id: options.user.id, name: options.user.name, display_name: options.user.displayName },
968
+ algorithms: options.algorithms,
969
+ exclude_credentials: options.excludeCredentials,
970
+ timeout_ms: options.timeoutMs,
971
+ };
972
+ }
860
973
  function tokenJson(tokens) {
861
974
  return {
862
975
  access_token: tokens.accessToken,
@@ -2152,6 +2265,11 @@ async function handle(req, res, options) {
2152
2265
  last_used_at: f.lastUsedAt?.toISOString() ?? null,
2153
2266
  })),
2154
2267
  recovery_codes_left: left,
2268
+ // What this installation can enrol, so the screen draws the
2269
+ // controls that work rather than one that answers 404. The same
2270
+ // "ask, do not assume" rule `GET /v1/auth/methods` exists for: a
2271
+ // deployment with no `NACRE_2FA_KEY` offers `webauthn` alone.
2272
+ kinds: factors.kinds,
2155
2273
  }, requestId);
2156
2274
  return;
2157
2275
  }
@@ -2170,6 +2288,84 @@ async function handle(req, res, options) {
2170
2288
  send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
2171
2289
  return;
2172
2290
  }
2291
+ /*
2292
+ * Enrolling a key, which is two calls because a ceremony is.
2293
+ *
2294
+ * The first hands out a challenge and what the authenticator needs to
2295
+ * see; the second brings back what it signed. They are separate because
2296
+ * the challenge has to exist in the database before the browser is asked
2297
+ * for anything — a server that made one up when the answer arrived would
2298
+ * be verifying a signature over a number the client chose.
2299
+ */
2300
+ if (rest === '/webauthn' && req.method === 'POST') {
2301
+ const begun = await factors.beginWebAuthnRegistration(auth.orgId, userId);
2302
+ if (begun === undefined) {
2303
+ // This installation offers TOTP only. 404 rather than a message
2304
+ // about configuration, for the reason the whole block is behind one.
2305
+ const problem = notFound(instance, requestId);
2306
+ send(res, problem.status, problem.toJSON(), requestId);
2307
+ return;
2308
+ }
2309
+ send(res, 201, registrationOptionsJson(begun), requestId);
2310
+ return;
2311
+ }
2312
+ if (rest === '/webauthn/finish' && req.method === 'POST') {
2313
+ const it = (body ?? {});
2314
+ const label = typeof it.label === 'string' && it.label.trim() !== '' ? it.label.trim().slice(0, 60) : 'Security key';
2315
+ const fields = ['challenge', 'attestation_object', 'client_data_json'];
2316
+ if (fields.some((f) => typeof it[f] !== 'string' || it[f] === '')) {
2317
+ const problem = badRequest(instance, requestId, "'challenge', 'attestation_object' and 'client_data_json' are required.");
2318
+ send(res, problem.status, problem.toJSON(), requestId);
2319
+ return;
2320
+ }
2321
+ const codes = await factors.finishWebAuthnRegistration(auth.orgId, userId, label, {
2322
+ attestationObject: new Uint8Array(Buffer.from(it.attestation_object, 'base64url')),
2323
+ clientDataJSON: new Uint8Array(Buffer.from(it.client_data_json, 'base64url')),
2324
+ challenge: it.challenge,
2325
+ });
2326
+ if (codes === undefined) {
2327
+ // One refusal for a spent challenge, a wrong origin, an unsupported
2328
+ // algorithm and a signature that does not verify. Telling them apart
2329
+ // would describe this server's checks to whoever is probing them.
2330
+ const problem = notFound(instance, requestId);
2331
+ send(res, problem.status, problem.toJSON(), requestId);
2332
+ return;
2333
+ }
2334
+ await options.audit.write({
2335
+ orgId: auth.orgId,
2336
+ actor: `user:${userId}`,
2337
+ action: 'second_factor.enrol',
2338
+ result: 'allow',
2339
+ target: { user_id: userId },
2340
+ detail: { kind: 'webauthn' },
2341
+ requestId,
2342
+ });
2343
+ void notifySecurityChange(options, auth.orgId, userId, 'enrolled', requestId);
2344
+ send(res, 200, { recovery_codes: codes }, requestId);
2345
+ return;
2346
+ }
2347
+ /*
2348
+ * A challenge for proving possession to *this* surface, which exists
2349
+ * because taking a factor off takes a current proof and an assertion
2350
+ * cannot be produced without one.
2351
+ *
2352
+ * `purpose = 'authenticate'` is the same pool a sign-in spends from, and
2353
+ * that is deliberate rather than an oversight: this caller has a session,
2354
+ * so a challenge they can spend on a sign-in buys them nothing they do
2355
+ * not already hold. What must not share a pool is *enrolment*, since
2356
+ * that one is asked for by somebody already signed in and would let a
2357
+ * session mint the input to a ceremony it is not in.
2358
+ */
2359
+ if (rest === '/webauthn/assert' && req.method === 'POST') {
2360
+ const begun = await factors.beginWebAuthnAssertion(auth.orgId, userId);
2361
+ if (begun === undefined) {
2362
+ const problem = notFound(instance, requestId);
2363
+ send(res, problem.status, problem.toJSON(), requestId);
2364
+ return;
2365
+ }
2366
+ send(res, 200, assertionOptionsJson(begun), requestId);
2367
+ return;
2368
+ }
2173
2369
  if (rest.endsWith('/confirm') && req.method === 'POST') {
2174
2370
  const id = rest.slice(1, -'/confirm'.length);
2175
2371
  const code = body?.code;
@@ -2206,21 +2402,43 @@ async function handle(req, res, options) {
2206
2402
  send(res, 200, { recovery_codes: codes }, requestId);
2207
2403
  return;
2208
2404
  }
2209
- if (rest !== '' && !rest.includes('/') && req.method === 'DELETE') {
2405
+ /*
2406
+ * Taking a factor off, and the condition is what it is because the one
2407
+ * it replaced could never be true. `rest` is `/{id}` for this route, so
2408
+ * `!rest.includes('/')` asked whether a string beginning with a slash
2409
+ * contains one — false for every id, always. `DELETE
2410
+ * /v1/me/second-factor/{id}` answered 404 from the day it was written
2411
+ * and shipped that way in 0.18.0: a second factor could be enrolled and
2412
+ * never removed, on the one surface an administrator deliberately
2413
+ * cannot reach on somebody's behalf.
2414
+ *
2415
+ * No suite could see it. `second-factor-live.test.ts` calls
2416
+ * `factors.remove` directly and passes, because the store was never the
2417
+ * broken half — nothing had asked the *server* for this path.
2418
+ */
2419
+ if (rest.startsWith('/') && !rest.slice(1).includes('/') && req.method === 'DELETE') {
2210
2420
  const id = rest.slice(1);
2211
- const code = body?.code;
2212
- if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
2213
- const problem = badRequest(instance, requestId, "'code' is required to remove a second factor.");
2421
+ const { code, assertion } = (body ?? {});
2422
+ const proof = readProof(code, assertion);
2423
+ if (!UUID_SHAPE.test(id) || proof === undefined) {
2424
+ const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required to remove a second factor.");
2214
2425
  send(res, problem.status, problem.toJSON(), requestId);
2215
2426
  return;
2216
2427
  }
2217
2428
  /*
2218
- * A current code to take one off, and that is the whole reason this
2429
+ * A current proof to take one off, and that is the whole reason this
2219
2430
  * endpoint takes a body at all. Removing the second factor is the first
2220
2431
  * thing somebody with a stolen session does, and a session is exactly
2221
2432
  * what the factor exists to be more than.
2433
+ *
2434
+ * Either kind, because an account can hold either kind — an
2435
+ * installation with no `NACRE_2FA_KEY` has no code to ask for, and
2436
+ * demanding one there would make every WebAuthn factor permanent.
2222
2437
  */
2223
- if (!(await factors.verify(auth.orgId, userId, code))) {
2438
+ const proved = proof.kind === 'code'
2439
+ ? await factors.verify(auth.orgId, userId, proof.code)
2440
+ : await factors.verifyWebAuthnAssertion(auth.orgId, userId, proof.response);
2441
+ if (!proved) {
2224
2442
  const problem = notFound(instance, requestId);
2225
2443
  send(res, problem.status, problem.toJSON(), requestId);
2226
2444
  return;