@spfn/auth 0.2.0-beta.86 → 0.2.0-beta.87

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.
@@ -345,7 +345,7 @@ function encodeString(value) {
345
345
  }
346
346
 
347
347
  // src/server/client-proof/proof.ts
348
- import { createHash, createHmac, timingSafeEqual } from "crypto";
348
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from "crypto";
349
349
  var CLIENT_PROOF_PROFILE = "clientProofV1";
350
350
  var ABSENT_BODY_SHA256 = "0".repeat(64);
351
351
  var DEFAULT_REPLAY_WINDOW_MILLIS = 3e5;
@@ -360,6 +360,10 @@ var PROOF_INPUT_FIELDS = [
360
360
  "bodySha256"
361
361
  ];
362
362
  var PROOF_INPUT_SEPARATOR = "\n";
363
+ var PROOF_SIGNATURE_BYTES = 64;
364
+ var PROOF_SIGNATURE_HEX_LENGTH = PROOF_SIGNATURE_BYTES * 2;
365
+ var PROOF_SIGNATURE_PATTERN = /^[0-9a-f]{128}$/;
366
+ var RAW_SIGNATURE_ENCODING = "ieee-p1363";
363
367
  var ProofInputError = class extends Error {
364
368
  constructor() {
365
369
  super("proof input field contains a C0 control character");
@@ -387,19 +391,43 @@ function canonicalProofInput(input) {
387
391
  }
388
392
  return fields.join(PROOF_INPUT_SEPARATOR);
389
393
  }
390
- function computeClientProof(input, key) {
391
- return createHmac("sha256", key).update(canonicalProofInput(input), "utf8").digest("hex");
392
- }
393
- function sha256Hex(bytes) {
394
- return createHash("sha256").update(bytes).digest("hex");
394
+ function parseClientProofPublicKey(spkiDerBase64) {
395
+ const key = createPublicKey({
396
+ key: Buffer.from(spkiDerBase64, "base64"),
397
+ format: "der",
398
+ type: "spki"
399
+ });
400
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
401
+ throw new Error("a clientProofV1 public key must be an ECDSA P-256 key");
402
+ }
403
+ return key;
395
404
  }
396
- function constantTimeEqualsProof(expected, presented) {
397
- const a = Buffer.from(expected, "utf8");
398
- const b = Buffer.from(presented, "utf8");
399
- if (a.length !== b.length) {
405
+ function verifyClientProof(input, presentedProof, publicKey) {
406
+ const data = Buffer.from(canonicalProofInput(input), "utf8");
407
+ if (!PROOF_SIGNATURE_PATTERN.test(presentedProof)) {
400
408
  return false;
401
409
  }
402
- return timingSafeEqual(a, b);
410
+ return verify(
411
+ "sha256",
412
+ data,
413
+ { key: publicKey, dsaEncoding: RAW_SIGNATURE_ENCODING },
414
+ Buffer.from(presentedProof, "hex")
415
+ );
416
+ }
417
+ function signClientProof(input, privateKeyPkcs8DerBase64) {
418
+ const key = createPrivateKey({
419
+ key: Buffer.from(privateKeyPkcs8DerBase64, "base64"),
420
+ format: "der",
421
+ type: "pkcs8"
422
+ });
423
+ return sign(
424
+ "sha256",
425
+ Buffer.from(canonicalProofInput(input), "utf8"),
426
+ { key, dsaEncoding: RAW_SIGNATURE_ENCODING }
427
+ ).toString("hex");
428
+ }
429
+ function sha256Hex(bytes) {
430
+ return createHash("sha256").update(bytes).digest("hex");
403
431
  }
404
432
 
405
433
  // src/server/client-proof/refusal.ts
@@ -490,6 +518,86 @@ function contractViolation(message) {
490
518
  return new ClientProofRefusal("CONTRACT_UNSUPPORTED", message);
491
519
  }
492
520
 
521
+ // src/server/client-proof/replay-store.ts
522
+ import { getCache } from "@spfn/core/cache";
523
+ function replayLedgerKey(clientId, nonce) {
524
+ return JSON.stringify([clientId, nonce]);
525
+ }
526
+ var MemoryReplayLedger = class {
527
+ /** replayLedgerKey(...) → the millis it was spent at. */
528
+ spent = /* @__PURE__ */ new Map();
529
+ isSpent(clientId, nonce) {
530
+ return this.spent.has(replayLedgerKey(clientId, nonce));
531
+ }
532
+ /** Records the pair at `atMillis`; false when it was already spent. */
533
+ spend(clientId, nonce, atMillis) {
534
+ const key = replayLedgerKey(clientId, nonce);
535
+ if (this.spent.has(key)) {
536
+ return false;
537
+ }
538
+ this.spent.set(key, atMillis);
539
+ return true;
540
+ }
541
+ /** Drops entries older than the window, judged against `nowMillis`. */
542
+ prune(nowMillis, windowMillis) {
543
+ for (const [key, spentAtMillis] of this.spent) {
544
+ if (nowMillis - spentAtMillis > windowMillis) {
545
+ this.spent.delete(key);
546
+ }
547
+ }
548
+ }
549
+ get size() {
550
+ return this.spent.size;
551
+ }
552
+ clear() {
553
+ this.spent.clear();
554
+ }
555
+ };
556
+ var MemoryReplayStore = class {
557
+ constructor(windowMillis = DEFAULT_REPLAY_WINDOW_MILLIS) {
558
+ this.windowMillis = windowMillis;
559
+ }
560
+ ledger = new MemoryReplayLedger();
561
+ async isSpent(clientId, nonce) {
562
+ this.ledger.prune(Date.now(), this.windowMillis);
563
+ return this.ledger.isSpent(clientId, nonce);
564
+ }
565
+ async spend(clientId, nonce) {
566
+ const now = Date.now();
567
+ this.ledger.prune(now, this.windowMillis);
568
+ return this.ledger.spend(clientId, nonce, now);
569
+ }
570
+ };
571
+ var RedisReplayStore = class {
572
+ constructor(windowMillis = DEFAULT_REPLAY_WINDOW_MILLIS) {
573
+ this.windowMillis = windowMillis;
574
+ }
575
+ async isSpent(clientId, nonce) {
576
+ return await this.cache().exists(this.key(clientId, nonce)) === 1;
577
+ }
578
+ async spend(clientId, nonce) {
579
+ return await this.cache().set(this.key(clientId, nonce), "1", "PX", this.windowMillis, "NX") === "OK";
580
+ }
581
+ cache() {
582
+ const cache = getCache();
583
+ if (!cache) {
584
+ throw new Error("client-proof replay ledger: cache is not available");
585
+ }
586
+ return cache;
587
+ }
588
+ key(clientId, nonce) {
589
+ return `spfn:auth:client-proof:replay:${sha256Hex(Buffer.from(replayLedgerKey(clientId, nonce), "utf8"))}`;
590
+ }
591
+ };
592
+ var configured = null;
593
+ function configureClientProofReplayStore(store) {
594
+ configured = store;
595
+ }
596
+ function getClientProofReplayStore() {
597
+ configured ??= new MemoryReplayStore();
598
+ return configured;
599
+ }
600
+
493
601
  // src/server/client-proof/state.ts
494
602
  function systemClock() {
495
603
  return { nowMillis: () => Date.now() };
@@ -506,16 +614,14 @@ var TestClock = class {
506
614
  }
507
615
  };
508
616
  var DEFAULT_SESSION_TTL_MILLIS = 6e5;
509
- function replayKeyOf(clientId, nonce) {
510
- return `${clientId}${nonce}`;
511
- }
512
617
  var ClientProofState = class {
513
618
  replayWindowMillis;
514
619
  clock;
515
- keys = /* @__PURE__ */ new Map();
620
+ initialPublicKeys;
621
+ publicKeys = /* @__PURE__ */ new Map();
516
622
  sessions = /* @__PURE__ */ new Map();
517
- /** replayKeyOf(...) the issuedAtMillis it was spent at. */
518
- spentNonces = /* @__PURE__ */ new Map();
623
+ /** The replay ledger — the shared memory implementation, used dev-only here. */
624
+ spentNonces = new MemoryReplayLedger();
519
625
  revokedKeyIds = /* @__PURE__ */ new Set();
520
626
  holds = /* @__PURE__ */ new Map();
521
627
  initialSessionTtlMillis;
@@ -530,10 +636,22 @@ var ClientProofState = class {
530
636
  this.initialSessionTtlMillis = options.sessionTtlMillis ?? DEFAULT_SESSION_TTL_MILLIS;
531
637
  this.sessionTtlMillis = this.initialSessionTtlMillis;
532
638
  this.replayWindowMillis = options.replayWindowMillis ?? DEFAULT_REPLAY_WINDOW_MILLIS;
533
- for (const [keyId, key] of Object.entries(options.keys)) {
534
- this.keys.set(keyId, typeof key === "string" ? new TextEncoder().encode(key) : key);
639
+ this.initialPublicKeys = new Map(
640
+ Object.entries(options.publicKeys).map(([keyId, spki]) => [keyId, parseClientProofPublicKey(spki)])
641
+ );
642
+ for (const [keyId, key] of this.initialPublicKeys) {
643
+ this.publicKeys.set(keyId, key);
535
644
  }
536
645
  }
646
+ // ---- key registration --------------------------------------------------
647
+ /**
648
+ * Registers (or replaces) the public key `keyId` presents proofs under.
649
+ *
650
+ * @throws when the key is not base64 SPKI DER naming a P-256 key.
651
+ */
652
+ registerPublicKey(keyId, publicKeySpkiDerBase64) {
653
+ this.publicKeys.set(keyId, parseClientProofPublicKey(publicKeySpkiDerBase64));
654
+ }
537
655
  // ---- admission ---------------------------------------------------------
538
656
  /**
539
657
  * Runs the contract's checks in the contract's order and returns the
@@ -555,18 +673,17 @@ var ClientProofState = class {
555
673
  if (age < 0 || age > this.replayWindowMillis) {
556
674
  return ClientProofRefusal.proofExpired();
557
675
  }
558
- const replayKey = replayKeyOf(args.clientId, args.proofInput.nonce);
559
- if (this.spentNonces.has(replayKey)) {
676
+ if (this.spentNonces.isSpent(args.clientId, args.proofInput.nonce)) {
560
677
  return ClientProofRefusal.proofReplayed();
561
678
  }
562
- const key = this.keys.get(args.keyId);
563
- if (key === void 0) {
679
+ const publicKey = this.publicKeys.get(args.keyId);
680
+ if (publicKey === void 0) {
564
681
  return ClientProofRefusal.proofInvalid();
565
682
  }
566
- if (!constantTimeEqualsProof(computeClientProof(args.proofInput, key), args.presentedProof)) {
683
+ if (!verifyClientProof(args.proofInput, args.presentedProof, publicKey)) {
567
684
  return ClientProofRefusal.proofInvalid();
568
685
  }
569
- this.spentNonces.set(replayKey, Number(args.proofInput.issuedAtMillis));
686
+ this.spentNonces.spend(args.clientId, args.proofInput.nonce, Number(args.proofInput.issuedAtMillis));
570
687
  return null;
571
688
  }
572
689
  // ---- sessions ----------------------------------------------------------
@@ -599,8 +716,12 @@ var ClientProofState = class {
599
716
  setSessionTtlMillis(millis) {
600
717
  this.sessionTtlMillis = millis;
601
718
  }
602
- /** Returns the state to how it started, counters included. */
719
+ /** Returns the state to how it started, counters and registered keys included. */
603
720
  reset() {
721
+ this.publicKeys.clear();
722
+ for (const [keyId, key] of this.initialPublicKeys) {
723
+ this.publicKeys.set(keyId, key);
724
+ }
604
725
  this.sessions.clear();
605
726
  this.spentNonces.clear();
606
727
  this.revokedKeyIds.clear();
@@ -678,11 +799,7 @@ var ClientProofState = class {
678
799
  this.sessions.delete(sessionId);
679
800
  }
680
801
  }
681
- for (const [key, issuedAtMillis] of this.spentNonces) {
682
- if (nowMillis - issuedAtMillis > this.replayWindowMillis) {
683
- this.spentNonces.delete(key);
684
- }
685
- }
802
+ this.spentNonces.prune(nowMillis, this.replayWindowMillis);
686
803
  }
687
804
  };
688
805
 
@@ -826,6 +943,48 @@ var CONTRACT_OPERATIONS = [
826
943
  summary: "Authenticated paged read covering optional fields and arrays."
827
944
  }
828
945
  ];
946
+ var AUTH_SURFACE_OPERATIONS = [
947
+ {
948
+ id: "auth.enroll.register",
949
+ method: "POST",
950
+ path: "/_auth/register",
951
+ authProfile: "none",
952
+ requiresSession: false,
953
+ requestType: "RegisterRequest",
954
+ responseType: "RegisterResponse",
955
+ summary: "Registers an account with a verification token and enrolls the client-generated public key."
956
+ },
957
+ {
958
+ id: "auth.enroll.login",
959
+ method: "POST",
960
+ path: "/_auth/login",
961
+ authProfile: "none",
962
+ requiresSession: false,
963
+ requestType: "LoginRequest",
964
+ responseType: "LoginResponse",
965
+ summary: "Authenticates with password credentials and enrolls a fresh client-generated public key."
966
+ },
967
+ {
968
+ id: "auth.enroll.oauthNative",
969
+ method: "POST",
970
+ path: "/_auth/oauth/{provider}/native",
971
+ authProfile: "none",
972
+ requiresSession: false,
973
+ requestType: "OauthNativeRequest",
974
+ responseType: "OauthNativeResponse",
975
+ summary: "Verifies a native/web social id_token server-side and enrolls the client-generated public key."
976
+ },
977
+ {
978
+ id: "auth.keys.rotate",
979
+ method: "POST",
980
+ path: "/_auth/keys/rotate",
981
+ authProfile: "clientProofV1",
982
+ requiresSession: false,
983
+ requestType: "RotateKeyRequest",
984
+ responseType: "RotateKeyResponse",
985
+ summary: "Replaces the authenticated key with a new client-generated public key before its TTL runs out."
986
+ }
987
+ ];
829
988
  var ContractTypeError = class extends Error {
830
989
  constructor() {
831
990
  super("not the declared contract type");
@@ -937,6 +1096,8 @@ async function handleControlRequest(state, controlToken, path, request) {
937
1096
  case "/control/expire-sessions":
938
1097
  state.expireSessions();
939
1098
  return ok();
1099
+ case "/control/register-key":
1100
+ return registerKey(state, body);
940
1101
  case "/control/revoke-key":
941
1102
  return revokeKey(state, body);
942
1103
  case "/control/session-ttl":
@@ -961,6 +1122,22 @@ function stats(state) {
961
1122
  ["spentNonceCount", BigInt(counters.spentNonceCount)]
962
1123
  ])));
963
1124
  }
1125
+ function registerKey(state, body) {
1126
+ const keyId = stringField(body, "keyId");
1127
+ const publicKey = stringField(body, "publicKey");
1128
+ if (keyId === null) {
1129
+ return badRequest("keyId");
1130
+ }
1131
+ if (publicKey === null) {
1132
+ return badRequest("publicKey");
1133
+ }
1134
+ try {
1135
+ state.registerPublicKey(keyId, publicKey);
1136
+ } catch {
1137
+ return badRequest("publicKey");
1138
+ }
1139
+ return ok();
1140
+ }
964
1141
  function revokeKey(state, body) {
965
1142
  const keyId = stringField(body, "keyId");
966
1143
  if (keyId === null) {
@@ -1226,6 +1403,7 @@ function createClientProofGuard(state, options = {}) {
1226
1403
  }
1227
1404
  export {
1228
1405
  ABSENT_BODY_SHA256,
1406
+ AUTH_SURFACE_OPERATIONS,
1229
1407
  CLIENT_PROOF_CONTENT_TYPE,
1230
1408
  CLIENT_PROOF_HEADERS,
1231
1409
  CLIENT_PROOF_PROFILE,
@@ -1240,12 +1418,16 @@ export {
1240
1418
  DEFAULT_SESSION_TTL_MILLIS,
1241
1419
  DEV_CATALOGUE,
1242
1420
  DEV_MAX_LIMIT,
1421
+ MemoryReplayLedger,
1422
+ MemoryReplayStore,
1423
+ PROOF_SIGNATURE_BYTES,
1424
+ PROOF_SIGNATURE_HEX_LENGTH,
1243
1425
  ProofInputError,
1426
+ RedisReplayStore,
1244
1427
  TestClock,
1245
1428
  admitClientProofRequest,
1246
1429
  canonicalProofInput,
1247
- computeClientProof,
1248
- constantTimeEqualsProof,
1430
+ configureClientProofReplayStore,
1249
1431
  createClientProofDevHandler,
1250
1432
  createClientProofGuard,
1251
1433
  decodeEchoRequest,
@@ -1255,10 +1437,17 @@ export {
1255
1437
  encodeEchoResponse,
1256
1438
  encodeHandshakeResponse,
1257
1439
  encodeListItemsResponse,
1440
+ getClientProofReplayStore,
1258
1441
  isCanonicalBytes,
1442
+ isRequestContentType,
1259
1443
  newHexId,
1260
1444
  parseCanonicalJson,
1445
+ parseClientProofPublicKey,
1446
+ readCredentials,
1447
+ replayLedgerKey,
1261
1448
  sha256Hex,
1262
- systemClock
1449
+ signClientProof,
1450
+ systemClock,
1451
+ verifyClientProof
1263
1452
  };
1264
1453
  //# sourceMappingURL=client-proof.js.map