@spfn/auth 0.2.0-beta.85 → 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,10 +345,25 @@ 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;
352
+ var PROOF_INPUT_FIELDS = [
353
+ "profile",
354
+ "method",
355
+ "path",
356
+ "clientId",
357
+ "keyId",
358
+ "nonce",
359
+ "issuedAtMillis",
360
+ "bodySha256"
361
+ ];
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";
352
367
  var ProofInputError = class extends Error {
353
368
  constructor() {
354
369
  super("proof input field contains a C0 control character");
@@ -356,16 +371,17 @@ var ProofInputError = class extends Error {
356
371
  }
357
372
  };
358
373
  function canonicalProofInput(input) {
359
- const fields = [
360
- CLIENT_PROOF_PROFILE,
361
- input.method,
362
- input.path,
363
- input.clientId,
364
- input.keyId,
365
- input.nonce,
366
- input.issuedAtMillis.toString(),
367
- input.bodySha256
368
- ];
374
+ const values = {
375
+ profile: CLIENT_PROOF_PROFILE,
376
+ method: input.method,
377
+ path: input.path,
378
+ clientId: input.clientId,
379
+ keyId: input.keyId,
380
+ nonce: input.nonce,
381
+ issuedAtMillis: input.issuedAtMillis.toString(),
382
+ bodySha256: input.bodySha256
383
+ };
384
+ const fields = PROOF_INPUT_FIELDS.map((name) => values[name]);
369
385
  for (const field of fields) {
370
386
  for (const ch of field) {
371
387
  if (ch.codePointAt(0) < 32) {
@@ -373,21 +389,45 @@ function canonicalProofInput(input) {
373
389
  }
374
390
  }
375
391
  }
376
- return fields.join("\n");
392
+ return fields.join(PROOF_INPUT_SEPARATOR);
377
393
  }
378
- function computeClientProof(input, key) {
379
- return createHmac("sha256", key).update(canonicalProofInput(input), "utf8").digest("hex");
380
- }
381
- function sha256Hex(bytes) {
382
- 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;
383
404
  }
384
- function constantTimeEqualsProof(expected, presented) {
385
- const a = Buffer.from(expected, "utf8");
386
- const b = Buffer.from(presented, "utf8");
387
- 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)) {
388
408
  return false;
389
409
  }
390
- 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");
391
431
  }
392
432
 
393
433
  // src/server/client-proof/refusal.ts
@@ -478,6 +518,86 @@ function contractViolation(message) {
478
518
  return new ClientProofRefusal("CONTRACT_UNSUPPORTED", message);
479
519
  }
480
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
+
481
601
  // src/server/client-proof/state.ts
482
602
  function systemClock() {
483
603
  return { nowMillis: () => Date.now() };
@@ -494,16 +614,14 @@ var TestClock = class {
494
614
  }
495
615
  };
496
616
  var DEFAULT_SESSION_TTL_MILLIS = 6e5;
497
- function replayKeyOf(clientId, nonce) {
498
- return `${clientId}${nonce}`;
499
- }
500
617
  var ClientProofState = class {
501
618
  replayWindowMillis;
502
619
  clock;
503
- keys = /* @__PURE__ */ new Map();
620
+ initialPublicKeys;
621
+ publicKeys = /* @__PURE__ */ new Map();
504
622
  sessions = /* @__PURE__ */ new Map();
505
- /** replayKeyOf(...) the issuedAtMillis it was spent at. */
506
- spentNonces = /* @__PURE__ */ new Map();
623
+ /** The replay ledger — the shared memory implementation, used dev-only here. */
624
+ spentNonces = new MemoryReplayLedger();
507
625
  revokedKeyIds = /* @__PURE__ */ new Set();
508
626
  holds = /* @__PURE__ */ new Map();
509
627
  initialSessionTtlMillis;
@@ -518,10 +636,22 @@ var ClientProofState = class {
518
636
  this.initialSessionTtlMillis = options.sessionTtlMillis ?? DEFAULT_SESSION_TTL_MILLIS;
519
637
  this.sessionTtlMillis = this.initialSessionTtlMillis;
520
638
  this.replayWindowMillis = options.replayWindowMillis ?? DEFAULT_REPLAY_WINDOW_MILLIS;
521
- for (const [keyId, key] of Object.entries(options.keys)) {
522
- 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);
523
644
  }
524
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
+ }
525
655
  // ---- admission ---------------------------------------------------------
526
656
  /**
527
657
  * Runs the contract's checks in the contract's order and returns the
@@ -543,18 +673,17 @@ var ClientProofState = class {
543
673
  if (age < 0 || age > this.replayWindowMillis) {
544
674
  return ClientProofRefusal.proofExpired();
545
675
  }
546
- const replayKey = replayKeyOf(args.clientId, args.proofInput.nonce);
547
- if (this.spentNonces.has(replayKey)) {
676
+ if (this.spentNonces.isSpent(args.clientId, args.proofInput.nonce)) {
548
677
  return ClientProofRefusal.proofReplayed();
549
678
  }
550
- const key = this.keys.get(args.keyId);
551
- if (key === void 0) {
679
+ const publicKey = this.publicKeys.get(args.keyId);
680
+ if (publicKey === void 0) {
552
681
  return ClientProofRefusal.proofInvalid();
553
682
  }
554
- if (!constantTimeEqualsProof(computeClientProof(args.proofInput, key), args.presentedProof)) {
683
+ if (!verifyClientProof(args.proofInput, args.presentedProof, publicKey)) {
555
684
  return ClientProofRefusal.proofInvalid();
556
685
  }
557
- this.spentNonces.set(replayKey, Number(args.proofInput.issuedAtMillis));
686
+ this.spentNonces.spend(args.clientId, args.proofInput.nonce, Number(args.proofInput.issuedAtMillis));
558
687
  return null;
559
688
  }
560
689
  // ---- sessions ----------------------------------------------------------
@@ -587,8 +716,12 @@ var ClientProofState = class {
587
716
  setSessionTtlMillis(millis) {
588
717
  this.sessionTtlMillis = millis;
589
718
  }
590
- /** Returns the state to how it started, counters included. */
719
+ /** Returns the state to how it started, counters and registered keys included. */
591
720
  reset() {
721
+ this.publicKeys.clear();
722
+ for (const [keyId, key] of this.initialPublicKeys) {
723
+ this.publicKeys.set(keyId, key);
724
+ }
592
725
  this.sessions.clear();
593
726
  this.spentNonces.clear();
594
727
  this.revokedKeyIds.clear();
@@ -666,11 +799,7 @@ var ClientProofState = class {
666
799
  this.sessions.delete(sessionId);
667
800
  }
668
801
  }
669
- for (const [key, issuedAtMillis] of this.spentNonces) {
670
- if (nowMillis - issuedAtMillis > this.replayWindowMillis) {
671
- this.spentNonces.delete(key);
672
- }
673
- }
802
+ this.spentNonces.prune(nowMillis, this.replayWindowMillis);
674
803
  }
675
804
  };
676
805
 
@@ -783,9 +912,78 @@ function isRequestContentType(value) {
783
912
 
784
913
  // src/server/client-proof/contract-types.ts
785
914
  var CONTRACT_OPERATIONS = [
786
- { id: "auth.clientProof.handshake", method: "POST", path: "/v1/auth/client-proof/handshake", requiresSession: false },
787
- { id: "echo.send", method: "POST", path: "/v1/echo", requiresSession: true },
788
- { id: "items.list", method: "POST", path: "/v1/items/list", requiresSession: true }
915
+ {
916
+ id: "auth.clientProof.handshake",
917
+ method: "POST",
918
+ path: "/v1/auth/client-proof/handshake",
919
+ authProfile: "clientProofV1",
920
+ requiresSession: false,
921
+ requestType: "HandshakeRequest",
922
+ responseType: "HandshakeResponse",
923
+ summary: "Presents a client proof and opens a session."
924
+ },
925
+ {
926
+ id: "echo.send",
927
+ method: "POST",
928
+ path: "/v1/echo",
929
+ authProfile: "clientProofV1",
930
+ requiresSession: true,
931
+ requestType: "EchoRequest",
932
+ responseType: "EchoResponse",
933
+ summary: "Authenticated round trip used as the smallest real vertical slice."
934
+ },
935
+ {
936
+ id: "items.list",
937
+ method: "POST",
938
+ path: "/v1/items/list",
939
+ authProfile: "clientProofV1",
940
+ requiresSession: true,
941
+ requestType: "ListItemsRequest",
942
+ responseType: "ListItemsResponse",
943
+ summary: "Authenticated paged read covering optional fields and arrays."
944
+ }
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
+ }
789
987
  ];
790
988
  var ContractTypeError = class extends Error {
791
989
  constructor() {
@@ -898,6 +1096,8 @@ async function handleControlRequest(state, controlToken, path, request) {
898
1096
  case "/control/expire-sessions":
899
1097
  state.expireSessions();
900
1098
  return ok();
1099
+ case "/control/register-key":
1100
+ return registerKey(state, body);
901
1101
  case "/control/revoke-key":
902
1102
  return revokeKey(state, body);
903
1103
  case "/control/session-ttl":
@@ -922,6 +1122,22 @@ function stats(state) {
922
1122
  ["spentNonceCount", BigInt(counters.spentNonceCount)]
923
1123
  ])));
924
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
+ }
925
1141
  function revokeKey(state, body) {
926
1142
  const keyId = stringField(body, "keyId");
927
1143
  if (keyId === null) {
@@ -1187,6 +1403,7 @@ function createClientProofGuard(state, options = {}) {
1187
1403
  }
1188
1404
  export {
1189
1405
  ABSENT_BODY_SHA256,
1406
+ AUTH_SURFACE_OPERATIONS,
1190
1407
  CLIENT_PROOF_CONTENT_TYPE,
1191
1408
  CLIENT_PROOF_HEADERS,
1192
1409
  CLIENT_PROOF_PROFILE,
@@ -1201,12 +1418,16 @@ export {
1201
1418
  DEFAULT_SESSION_TTL_MILLIS,
1202
1419
  DEV_CATALOGUE,
1203
1420
  DEV_MAX_LIMIT,
1421
+ MemoryReplayLedger,
1422
+ MemoryReplayStore,
1423
+ PROOF_SIGNATURE_BYTES,
1424
+ PROOF_SIGNATURE_HEX_LENGTH,
1204
1425
  ProofInputError,
1426
+ RedisReplayStore,
1205
1427
  TestClock,
1206
1428
  admitClientProofRequest,
1207
1429
  canonicalProofInput,
1208
- computeClientProof,
1209
- constantTimeEqualsProof,
1430
+ configureClientProofReplayStore,
1210
1431
  createClientProofDevHandler,
1211
1432
  createClientProofGuard,
1212
1433
  decodeEchoRequest,
@@ -1216,10 +1437,17 @@ export {
1216
1437
  encodeEchoResponse,
1217
1438
  encodeHandshakeResponse,
1218
1439
  encodeListItemsResponse,
1440
+ getClientProofReplayStore,
1219
1441
  isCanonicalBytes,
1442
+ isRequestContentType,
1220
1443
  newHexId,
1221
1444
  parseCanonicalJson,
1445
+ parseClientProofPublicKey,
1446
+ readCredentials,
1447
+ replayLedgerKey,
1222
1448
  sha256Hex,
1223
- systemClock
1449
+ signClientProof,
1450
+ systemClock,
1451
+ verifyClientProof
1224
1452
  };
1225
1453
  //# sourceMappingURL=client-proof.js.map