@mikeargento/bitgraph-verify 1.2.0 → 1.4.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/src/verifier.ts CHANGED
@@ -578,6 +578,28 @@ function checkPolicy(proof: BitGraphProof, policy: VerificationPolicy): string |
578
578
  }
579
579
  }
580
580
 
581
+ // WebAuthn origin allowlist. The origin's agreement with the rpIdHash has
582
+ // already been checked structurally in verifyAgency; this is the policy
583
+ // that names WHICH origins a reader accepts.
584
+ if (policy.allowedOrigins !== undefined && policy.allowedOrigins.length > 0) {
585
+ if (proof.agency === undefined) {
586
+ return "policy requires an allowed origin but proof has no agency";
587
+ }
588
+ const auth = proof.agency.authorization;
589
+ if (!("format" in auth) || auth.format !== "webauthn") {
590
+ return "policy requires an allowed WebAuthn origin but the authorization is not WebAuthn";
591
+ }
592
+ let origin: string | undefined;
593
+ try {
594
+ origin = (JSON.parse(auth.clientDataJSON) as { origin?: string }).origin;
595
+ } catch {
596
+ origin = undefined;
597
+ }
598
+ if (typeof origin !== "string" || !policy.allowedOrigins.includes(origin)) {
599
+ return `WebAuthn origin "${origin ?? "(none)"}" is not in the allowed set`;
600
+ }
601
+ }
602
+
581
603
  // Slot allocation required (BitGraph causal ordering)
582
604
  if (policy.requireSlot === true) {
583
605
  if (proof.slotAllocation === undefined) {
@@ -594,6 +616,44 @@ function checkPolicy(proof: BitGraphProof, policy: VerificationPolicy): string |
594
616
  // Agency verification (P-256 device signature)
595
617
  // ---------------------------------------------------------------------------
596
618
 
619
+ /**
620
+ * The RP-binding half of WebAuthn assertion verification: the origin in the
621
+ * client data must be a well-formed origin, and the first 32 bytes of the
622
+ * authenticator data (rpIdHash) must be SHA-256 of that origin's host or of
623
+ * a parent domain of it with at least two labels (the RP ID is allowed to be
624
+ * a registrable suffix of the origin's effective domain; without a public
625
+ * suffix list, "at least two labels" is the usual approximation and errs on
626
+ * the permissive side). A single-label host such as localhost is its own RP
627
+ * ID. Returns a reason string or null.
628
+ */
629
+ function checkWebAuthnOrigin(origin: unknown, authData: Buffer): string | null {
630
+ if (typeof origin !== "string" || origin.length === 0) {
631
+ return "agency: clientDataJSON missing origin field";
632
+ }
633
+ let host: string;
634
+ try {
635
+ host = new URL(origin).hostname.toLowerCase();
636
+ } catch {
637
+ return "agency: clientDataJSON origin is not a valid origin";
638
+ }
639
+ if (!host) return "agency: clientDataJSON origin has no host";
640
+ const rpIdHash = authData.subarray(0, 32).toString("hex");
641
+ const labels = host.split(".");
642
+ const candidates: string[] = [];
643
+ for (let i = 0; i < labels.length; i++) {
644
+ const candidate = labels.slice(i).join(".");
645
+ if (i > 0 && labels.length - i < 2) break; // no bare TLDs
646
+ candidates.push(candidate);
647
+ }
648
+ const matches = candidates.some(
649
+ (c) => createHash("sha256").update(c, "utf8").digest("hex") === rpIdHash,
650
+ );
651
+ if (!matches) {
652
+ return "agency: authenticatorData rpIdHash does not match the origin's domain";
653
+ }
654
+ return null;
655
+ }
656
+
597
657
  /**
598
658
  * Verify the agency envelope: P-256 signature, structural consistency,
599
659
  * and artifact binding.
@@ -717,6 +777,19 @@ function verifyAgency(proof: BitGraphProof): string | null {
717
777
  if (!(flags & 0x01)) return "agency: authenticatorData UP flag not set";
718
778
  if (!(flags & 0x04)) return "agency: authenticatorData UV flag not set";
719
779
 
780
+ // Origin and RP binding. The client data names the origin the
781
+ // assertion was made on; the authenticator data opens with the hash
782
+ // of the RP ID it scoped the credential to. WebAuthn requires the RP
783
+ // ID to be the origin's host or a registrable parent of it, so the
784
+ // two must agree, and a verifier that reads only the JSON has taken
785
+ // the client's word for which site asked. This is the RP-binding
786
+ // check the WebAuthn verification procedure lists; it is structural
787
+ // (any origin, as long as the authenticator agrees), and a SPECIFIC
788
+ // origin is a policy (VerificationPolicy.allowedOrigins), because
789
+ // the protocol can be run under any domain.
790
+ const originError = checkWebAuthnOrigin(clientData.origin, authData);
791
+ if (originError !== null) return originError;
792
+
720
793
  // Build signed data: authenticatorData || SHA-256(clientDataJSON)
721
794
  const clientDataHash = createHash("sha256")
722
795
  .update(Buffer.from(webauthn.clientDataJSON, "utf8"))