@peac/protocol 0.16.3 → 0.16.5

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/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { uuidv7 } from 'uuidv7';
2
- import { sign, decode, verify, jcsHash, sha256Hex, computeJwkThumbprint, jwkToPublicKeyBytes, base64urlDecode, signWire02, base64urlDecodeString, base64urlEncode } from '@peac/crypto';
2
+ import { sign, CryptoError, decode, verify, jcsHash, sha256Hex, computeJwkThumbprint, jwkToPublicKeyBytes, base64urlDecode, signWire02, base64urlDecodeString, base64urlEncode } from '@peac/crypto';
3
3
  export { base64urlDecode, base64urlEncode, computeJwkThumbprint, generateKeypair, jwkToPublicKeyBytes, sha256Bytes, sha256Hex, verify } from '@peac/crypto';
4
4
  import { isValidPurposeToken, isCanonicalPurpose, isValidPurposeReason, isValidWorkflowContext, createWorkflowContextInvalidError, hasValidDagSemantics, createWorkflowDagInvalidError, WORKFLOW_EXTENSION_KEY, validateKernelConstraints, createConstraintViolationError, ReceiptClaims, createEvidenceNotJsonError, validateSubjectSnapshot, isCanonicalIss, Wire02ClaimsSchema, PEAC_ISSUER_CONFIG_MAX_BYTES, validateRevokedKeys, PEAC_ISSUER_CONFIG_PATH, PEAC_POLICY_MAX_BYTES, PEAC_POLICY_PATH, PEAC_POLICY_FALLBACK_PATH, WARNING_TYP_MISSING, parseReceiptClaims, checkOccurredAtSkew, REGISTERED_RECEIPT_TYPES, WARNING_TYPE_UNREGISTERED, REGISTERED_EXTENSION_GROUP_KEYS, isValidExtensionKey, WARNING_UNKNOWN_EXTENSION, WARNING_EXTENSION_GROUP_MISSING, WARNING_EXTENSION_GROUP_MISMATCH, verifyPolicyBinding, sortWarnings, PEAC_RECEIPT_HEADER, PEAC_PURPOSE_HEADER, parsePurposeHeader, PEAC_PURPOSE_APPLIED_HEADER, PEAC_PURPOSE_REASON_HEADER, KERNEL_CONSTRAINTS } from '@peac/schema';
5
5
  import { VERIFIER_LIMITS, VERIFIER_NETWORK, TYPE_TO_EXTENSION_MAP, HASH, VERIFIER_POLICY_VERSION, VERIFICATION_REPORT_VERSION, WIRE_TYPE, OCCURRED_AT_TOLERANCE_SECONDS, ISS_CANONICAL, EXTENSION_BUDGET } from '@peac/kernel';
@@ -210,13 +210,32 @@ function validateTypeExtensionMappingInternal(input) {
210
210
  }
211
211
 
212
212
  // src/_internal/record-core/validators/jose-hardening.ts
213
- var KID_MAX_LENGTH = 256;
213
+ var KID_MAX_UTF8_BYTES = 256;
214
+ function isAcceptableKid(kid) {
215
+ if (typeof kid !== "string" || kid.length === 0) return false;
216
+ let bytes = 0;
217
+ for (let i = 0; i < kid.length; i++) {
218
+ const c = kid.charCodeAt(i);
219
+ if (c < 128) bytes += 1;
220
+ else if (c < 2048) bytes += 2;
221
+ else if (c >= 55296 && c <= 56319) {
222
+ const next = i + 1 < kid.length ? kid.charCodeAt(i + 1) : 0;
223
+ if (next < 56320 || next > 57343) return false;
224
+ bytes += 4;
225
+ i++;
226
+ } else if (c >= 56320 && c <= 57343) {
227
+ return false;
228
+ } else bytes += 3;
229
+ if (bytes > KID_MAX_UTF8_BYTES) return false;
230
+ }
231
+ return true;
232
+ }
214
233
  var ACCEPTED = { accepted: true };
215
234
  function rejected(errorCode) {
216
235
  return { accepted: false, errorCode };
217
236
  }
218
237
  function validateJoseHardeningInternal(header) {
219
- if (!header.kid || typeof header.kid !== "string" || header.kid.length === 0 || header.kid.length > KID_MAX_LENGTH) {
238
+ if (!isAcceptableKid(header.kid)) {
220
239
  return rejected("CRYPTO_JWS_MISSING_KID");
221
240
  }
222
241
  if (header.jwk !== void 0 || header.x5c !== void 0 || header.x5u !== void 0 || header.jku !== void 0) {
@@ -941,6 +960,25 @@ var IssueError = class extends Error {
941
960
  this.peacError = peacError;
942
961
  }
943
962
  };
963
+ var CRYPTO_ISSUANCE_CODE_MAP = {
964
+ CRYPTO_JWS_MISSING_KID: "E_JWS_MISSING_KID",
965
+ CRYPTO_IJSON_INVALID_STRING: "E_IJSON_INVALID_STRING",
966
+ CRYPTO_IJSON_NUMBER_OUT_OF_RANGE: "E_IJSON_NUMBER_OUT_OF_RANGE",
967
+ CRYPTO_IJSON_DUPLICATE_MEMBER_NAME: "E_IJSON_DUPLICATE_MEMBER_NAME"
968
+ };
969
+ function issueErrorFromCryptoError(err) {
970
+ const code = CRYPTO_ISSUANCE_CODE_MAP[err.code] ?? "E_INVALID_FORMAT";
971
+ const issueError = new IssueError({
972
+ code,
973
+ category: "validation",
974
+ severity: "error",
975
+ retryable: false,
976
+ http_status: 400,
977
+ details: { message: err.message }
978
+ });
979
+ issueError.cause = err;
980
+ return issueError;
981
+ }
944
982
  async function issueWire01(options) {
945
983
  if (!options.iss.startsWith("https://")) {
946
984
  throw new Error("Issuer URL must start with https://");
@@ -1149,7 +1187,15 @@ async function issueWire02(options) {
1149
1187
  });
1150
1188
  }
1151
1189
  }
1152
- const jws = await defaultCodec.encode(claims, options.privateKey, options.kid);
1190
+ let jws;
1191
+ try {
1192
+ jws = await defaultCodec.encode(claims, options.privateKey, options.kid);
1193
+ } catch (err) {
1194
+ if (err instanceof CryptoError) {
1195
+ throw issueErrorFromCryptoError(err);
1196
+ }
1197
+ throw err;
1198
+ }
1153
1199
  if (legacyPath && isShadowEnabled(options)) {
1154
1200
  scheduleShadow({
1155
1201
  call: "issue",
@@ -1483,6 +1529,138 @@ async function fetchDiscovery(issuerUrl) {
1483
1529
  );
1484
1530
  }
1485
1531
  }
1532
+
1533
+ // src/_internal/address-policy.ts
1534
+ var IPV4_OCTET = /^(?:0|[1-9]\d{0,2})$/;
1535
+ var IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/;
1536
+ function parseIPv4(text) {
1537
+ const parts = text.split(".");
1538
+ if (parts.length !== 4) return null;
1539
+ let value = 0;
1540
+ for (const part of parts) {
1541
+ if (!IPV4_OCTET.test(part)) return null;
1542
+ const octet = Number(part);
1543
+ if (octet > 255) return null;
1544
+ value = value * 256 + octet;
1545
+ }
1546
+ return value;
1547
+ }
1548
+ function parseGroups(text) {
1549
+ if (text === "") return [];
1550
+ const groups = [];
1551
+ for (const group of text.split(":")) {
1552
+ if (!IPV6_GROUP.test(group)) return null;
1553
+ groups.push(parseInt(group, 16));
1554
+ }
1555
+ return groups;
1556
+ }
1557
+ function parseIPv6(text) {
1558
+ if (text.length === 0 || text.length > 45) return null;
1559
+ let body = text;
1560
+ const lastColon = body.lastIndexOf(":");
1561
+ if (lastColon === -1) return null;
1562
+ const last = body.slice(lastColon + 1);
1563
+ if (last.includes(".")) {
1564
+ const v4 = parseIPv4(last);
1565
+ if (v4 === null) return null;
1566
+ body = `${body.slice(0, lastColon + 1)}${Math.floor(v4 / 65536).toString(16)}:${(v4 % 65536).toString(16)}`;
1567
+ }
1568
+ const halves = body.split("::");
1569
+ if (halves.length > 2) return null;
1570
+ const head = parseGroups(halves[0]);
1571
+ if (head === null) return null;
1572
+ if (halves.length === 1) return head.length === 8 ? head : null;
1573
+ const tail = parseGroups(halves[1]);
1574
+ if (tail === null) return null;
1575
+ const fill = 8 - head.length - tail.length;
1576
+ if (fill < 1) return null;
1577
+ return [...head, ...new Array(fill).fill(0), ...tail];
1578
+ }
1579
+ var IPV4_BLOCKS = [
1580
+ ["0.0.0.0", 8, "private_ip"],
1581
+ ["10.0.0.0", 8, "private_ip"],
1582
+ ["100.64.0.0", 10, "private_ip"],
1583
+ ["127.0.0.0", 8, "loopback"],
1584
+ ["169.254.0.0", 16, "link_local"],
1585
+ ["172.16.0.0", 12, "private_ip"],
1586
+ ["192.0.0.0", 24, "private_ip"],
1587
+ ["192.0.2.0", 24, "private_ip"],
1588
+ ["192.31.196.0", 24, "private_ip"],
1589
+ ["192.52.193.0", 24, "private_ip"],
1590
+ ["192.88.99.0", 24, "private_ip"],
1591
+ ["192.168.0.0", 16, "private_ip"],
1592
+ ["192.175.48.0", 24, "private_ip"],
1593
+ ["198.18.0.0", 15, "private_ip"],
1594
+ ["198.51.100.0", 24, "private_ip"],
1595
+ ["203.0.113.0", 24, "private_ip"],
1596
+ ["224.0.0.0", 4, "private_ip"],
1597
+ ["240.0.0.0", 4, "private_ip"]
1598
+ ];
1599
+ var IPV4_BLOCK_TABLE = IPV4_BLOCKS.map(([network, prefix, reason]) => {
1600
+ const base = parseIPv4(network);
1601
+ const size = 2 ** (32 - prefix);
1602
+ return { start: base, end: base + size - 1, reason };
1603
+ });
1604
+ function classifyIPv4(value) {
1605
+ for (const block of IPV4_BLOCK_TABLE) {
1606
+ if (value >= block.start && value <= block.end) {
1607
+ return { allowed: false, reason: block.reason };
1608
+ }
1609
+ }
1610
+ return { allowed: true };
1611
+ }
1612
+ function matchesPrefix(groups, network, bits) {
1613
+ for (let i = 0; i < 8; i++) {
1614
+ const bitsHere = Math.min(16, Math.max(0, bits - 16 * i));
1615
+ if (bitsHere === 0) return true;
1616
+ const mask = 65535 << 16 - bitsHere & 65535;
1617
+ if ((groups[i] & mask) !== (network[i] & mask)) return false;
1618
+ }
1619
+ return true;
1620
+ }
1621
+ function v6(text) {
1622
+ return parseIPv6(text);
1623
+ }
1624
+ var GLOBAL_UNICAST_V6 = v6("2000::");
1625
+ var IPV6_BLOCKS_IN_GLOBAL = [
1626
+ [v6("2001::"), 23],
1627
+ [v6("2001:db8::"), 32],
1628
+ [v6("2002::"), 16],
1629
+ [v6("2620:4f:8000::"), 48],
1630
+ [v6("3fff::"), 20]
1631
+ ];
1632
+ var IPV6_LOOPBACK = v6("::1");
1633
+ var IPV6_LINK_LOCAL = v6("fe80::");
1634
+ var IPV4_MAPPED = v6("::ffff:0:0");
1635
+ var NAT64_WELL_KNOWN = v6("64:ff9b::");
1636
+ function classifyIPv6(groups) {
1637
+ if (matchesPrefix(groups, IPV4_MAPPED, 96) || matchesPrefix(groups, NAT64_WELL_KNOWN, 96)) {
1638
+ return classifyIPv4(groups[6] * 65536 + groups[7]);
1639
+ }
1640
+ if (!matchesPrefix(groups, GLOBAL_UNICAST_V6, 3)) {
1641
+ if (matchesPrefix(groups, IPV6_LOOPBACK, 128)) return { allowed: false, reason: "loopback" };
1642
+ if (matchesPrefix(groups, IPV6_LINK_LOCAL, 10)) return { allowed: false, reason: "link_local" };
1643
+ return { allowed: false, reason: "private_ip" };
1644
+ }
1645
+ for (const [network, bits] of IPV6_BLOCKS_IN_GLOBAL) {
1646
+ if (matchesPrefix(groups, network, bits)) return { allowed: false, reason: "private_ip" };
1647
+ }
1648
+ return { allowed: true };
1649
+ }
1650
+ function classifyAddress(ip) {
1651
+ const v4 = parseIPv4(ip);
1652
+ if (v4 !== null) return classifyIPv4(v4);
1653
+ const groups = parseIPv6(ip);
1654
+ if (groups !== null) return classifyIPv6(groups);
1655
+ return { allowed: false, reason: "private_ip" };
1656
+ }
1657
+ function hostIpLiteral(hostname) {
1658
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return hostname.slice(1, -1);
1659
+ if (/^[0-9.]+$/.test(hostname)) return hostname;
1660
+ return null;
1661
+ }
1662
+
1663
+ // src/ssrf-safe-fetch.ts
1486
1664
  var cachedCapabilities = null;
1487
1665
  function getSSRFCapabilities() {
1488
1666
  if (cachedCapabilities) {
@@ -1500,9 +1678,9 @@ function detectCapabilities() {
1500
1678
  networkIsolation: false,
1501
1679
  protectionLevel: "full",
1502
1680
  notes: [
1503
- "Full SSRF protection available via Node.js dns module",
1504
- "DNS resolution checked before HTTP connection",
1505
- "All RFC 1918 private ranges blocked"
1681
+ "Hostnames are resolved once via the Node.js dns module",
1682
+ "Every resolved address must be a global unicast address",
1683
+ "The connection is made only to a vetted address"
1506
1684
  ]
1507
1685
  };
1508
1686
  }
@@ -1580,61 +1758,16 @@ function detectCapabilities() {
1580
1758
  function resetSSRFCapabilitiesCache() {
1581
1759
  cachedCapabilities = null;
1582
1760
  }
1583
- function parseIPv4(ip) {
1584
- const parts = ip.split(".");
1585
- if (parts.length !== 4) return null;
1586
- const octets = [];
1587
- for (const part of parts) {
1588
- const num = parseInt(part, 10);
1589
- if (isNaN(num) || num < 0 || num > 255) return null;
1590
- octets.push(num);
1591
- }
1592
- return { octets };
1593
- }
1594
- function isInCIDR(ip, cidr) {
1595
- const [rangeStr, maskStr] = cidr.split("/");
1596
- const range = parseIPv4(rangeStr);
1597
- if (!range) return false;
1598
- const maskBits = parseInt(maskStr, 10);
1599
- if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
1600
- const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
1601
- const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
1602
- const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
1603
- return (ipNum & mask) === (rangeNum & mask);
1604
- }
1605
- function isIPv6Loopback(ip) {
1606
- const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
1607
- return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
1608
- }
1609
- function isIPv6LinkLocal(ip) {
1610
- const normalized = ip.toLowerCase();
1611
- return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
1612
- }
1613
1761
  function isBlockedIP(ip) {
1614
- const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
1615
- const effectiveIP = ipv4Match ? ipv4Match[1] : ip;
1616
- const ipv4 = parseIPv4(effectiveIP);
1617
- if (ipv4) {
1618
- if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
1619
- return { blocked: true, reason: "private_ip" };
1620
- }
1621
- if (isInCIDR(ipv4, "127.0.0.0/8")) {
1622
- return { blocked: true, reason: "loopback" };
1623
- }
1624
- if (isInCIDR(ipv4, "169.254.0.0/16")) {
1625
- return { blocked: true, reason: "link_local" };
1626
- }
1627
- return { blocked: false };
1628
- }
1629
- if (isIPv6Loopback(ip)) {
1630
- return { blocked: true, reason: "loopback" };
1631
- }
1632
- if (isIPv6LinkLocal(ip)) {
1633
- return { blocked: true, reason: "link_local" };
1634
- }
1635
- return { blocked: false };
1762
+ const verdict = classifyAddress(ip);
1763
+ return verdict.allowed ? { blocked: false } : { blocked: true, reason: verdict.reason };
1636
1764
  }
1637
1765
  async function resolveHostname(hostname) {
1766
+ const literal = hostIpLiteral(hostname);
1767
+ if (literal !== null) {
1768
+ const nodeRuntime = typeof process !== "undefined" && Boolean(process.versions?.node);
1769
+ return { ok: true, ips: [literal], browser: !nodeRuntime };
1770
+ }
1638
1771
  if (typeof process !== "undefined" && process.versions?.node) {
1639
1772
  try {
1640
1773
  const dns = await import('dns');
@@ -1665,7 +1798,10 @@ async function resolveHostname(hostname) {
1665
1798
  message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
1666
1799
  };
1667
1800
  }
1668
- return { ok: true, ips: [], browser: false };
1801
+ return {
1802
+ ok: false,
1803
+ message: `DNS resolution returned no addresses for ${hostname}`
1804
+ };
1669
1805
  } catch (err) {
1670
1806
  return {
1671
1807
  ok: false,
@@ -1675,6 +1811,88 @@ async function resolveHostname(hostname) {
1675
1811
  }
1676
1812
  return { ok: true, ips: [], browser: true };
1677
1813
  }
1814
+ var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
1815
+ async function discardBody(response) {
1816
+ await response.body?.cancel();
1817
+ }
1818
+ async function fetchFromVettedAddresses(url, addresses, headers, signal) {
1819
+ const https = await import('https');
1820
+ const { isIP } = await import('net');
1821
+ const target = new URL(url);
1822
+ const host = hostIpLiteral(target.hostname) ?? target.hostname;
1823
+ if (addresses.length === 0 || isIP(host) !== 0 && !addresses.includes(host)) {
1824
+ throw new Error(`Refusing to connect to an address that was not vetted: ${host}`);
1825
+ }
1826
+ const entries = addresses.map((address) => ({
1827
+ address,
1828
+ family: address.includes(":") ? 6 : 4
1829
+ }));
1830
+ const lookup = (_hostname, options, callback) => {
1831
+ if (options.all) {
1832
+ callback(null, entries);
1833
+ } else {
1834
+ callback(null, entries[0].address, entries[0].family);
1835
+ }
1836
+ };
1837
+ return new Promise((resolve, reject) => {
1838
+ const req = https.request(
1839
+ target,
1840
+ { method: "GET", headers, lookup, agent: false, signal },
1841
+ (res) => {
1842
+ try {
1843
+ const status = res.statusCode ?? 0;
1844
+ const responseHeaders = new Headers();
1845
+ for (const [name, value] of Object.entries(res.headers)) {
1846
+ if (value === void 0) continue;
1847
+ if (Array.isArray(value)) {
1848
+ for (const item of value) responseHeaders.append(name, item);
1849
+ } else {
1850
+ responseHeaders.set(name, value);
1851
+ }
1852
+ }
1853
+ if (NULL_BODY_STATUSES.has(status)) {
1854
+ res.resume();
1855
+ resolve(new Response(null, { status, headers: responseHeaders }));
1856
+ return;
1857
+ }
1858
+ let settled = false;
1859
+ const body = new ReadableStream({
1860
+ start(controller) {
1861
+ res.on("data", (chunk) => {
1862
+ if (settled) return;
1863
+ controller.enqueue(new Uint8Array(chunk));
1864
+ if ((controller.desiredSize ?? 0) <= 0) res.pause();
1865
+ });
1866
+ res.on("end", () => {
1867
+ if (settled) return;
1868
+ settled = true;
1869
+ controller.close();
1870
+ });
1871
+ res.on("error", (err) => {
1872
+ if (settled) return;
1873
+ settled = true;
1874
+ controller.error(err);
1875
+ });
1876
+ },
1877
+ pull() {
1878
+ res.resume();
1879
+ },
1880
+ cancel() {
1881
+ settled = true;
1882
+ res.destroy();
1883
+ }
1884
+ });
1885
+ resolve(new Response(body, { status, headers: responseHeaders }));
1886
+ } catch (err) {
1887
+ res.destroy();
1888
+ reject(err);
1889
+ }
1890
+ }
1891
+ );
1892
+ req.on("error", reject);
1893
+ req.end();
1894
+ });
1895
+ }
1678
1896
  async function ssrfSafeFetch(url, options = {}) {
1679
1897
  const {
1680
1898
  timeoutMs = VERIFIER_LIMITS.fetchTimeoutMs,
@@ -1723,38 +1941,44 @@ async function ssrfSafeFetch(url, options = {}) {
1723
1941
  blockedUrl: url
1724
1942
  };
1725
1943
  }
1726
- if (!dnsResult.browser) {
1727
- for (const ip of dnsResult.ips) {
1728
- const blockResult = isBlockedIP(ip);
1729
- if (blockResult.blocked) {
1730
- return {
1731
- ok: false,
1732
- reason: blockResult.reason,
1733
- message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
1734
- blockedUrl: url
1735
- };
1736
- }
1944
+ for (const ip of dnsResult.ips) {
1945
+ const blockResult = isBlockedIP(ip);
1946
+ if (blockResult.blocked) {
1947
+ return {
1948
+ ok: false,
1949
+ reason: blockResult.reason,
1950
+ message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
1951
+ blockedUrl: url
1952
+ };
1737
1953
  }
1738
1954
  }
1739
1955
  let redirectCount = 0;
1740
1956
  let currentUrl = url;
1957
+ let vettedAddresses = dnsResult.browser ? null : dnsResult.ips;
1741
1958
  const originalOrigin = parsedUrl.origin;
1742
1959
  while (true) {
1743
1960
  try {
1744
1961
  const controller = new AbortController();
1745
1962
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1746
- const response = await fetch(currentUrl, {
1747
- headers: {
1748
- Accept: "application/json, text/plain",
1749
- ...headers
1750
- },
1963
+ const requestHeaders = {
1964
+ Accept: "application/json, text/plain",
1965
+ ...headers
1966
+ };
1967
+ const response = vettedAddresses === null ? await fetch(currentUrl, {
1968
+ headers: requestHeaders,
1751
1969
  signal: controller.signal,
1752
1970
  redirect: "manual"
1753
1971
  // Handle redirects manually for security
1754
- });
1972
+ }) : await fetchFromVettedAddresses(
1973
+ currentUrl,
1974
+ vettedAddresses,
1975
+ requestHeaders,
1976
+ controller.signal
1977
+ );
1755
1978
  clearTimeout(timeoutId);
1756
1979
  if (response.status >= 300 && response.status < 400) {
1757
1980
  const location = response.headers.get("location");
1981
+ await discardBody(response);
1758
1982
  if (!location) {
1759
1983
  return {
1760
1984
  ok: false,
@@ -1823,24 +2047,24 @@ async function ssrfSafeFetch(url, options = {}) {
1823
2047
  blockedUrl: redirectUrl.href
1824
2048
  };
1825
2049
  }
1826
- if (!redirectDnsResult.browser) {
1827
- for (const ip of redirectDnsResult.ips) {
1828
- const blockResult = isBlockedIP(ip);
1829
- if (blockResult.blocked) {
1830
- return {
1831
- ok: false,
1832
- reason: blockResult.reason,
1833
- message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
1834
- blockedUrl: redirectUrl.href
1835
- };
1836
- }
2050
+ for (const ip of redirectDnsResult.ips) {
2051
+ const blockResult = isBlockedIP(ip);
2052
+ if (blockResult.blocked) {
2053
+ return {
2054
+ ok: false,
2055
+ reason: blockResult.reason,
2056
+ message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
2057
+ blockedUrl: redirectUrl.href
2058
+ };
1837
2059
  }
1838
2060
  }
1839
2061
  currentUrl = redirectUrl.href;
2062
+ vettedAddresses = redirectDnsResult.browser ? null : redirectDnsResult.ips;
1840
2063
  continue;
1841
2064
  }
1842
2065
  const contentLength = response.headers.get("content-length");
1843
2066
  if (contentLength && parseInt(contentLength, 10) > maxBytes) {
2067
+ await discardBody(response);
1844
2068
  return {
1845
2069
  ok: false,
1846
2070
  reason: "response_too_large",
@@ -2071,6 +2295,25 @@ function mapSSRFError(reason, context) {
2071
2295
  }
2072
2296
  return { code, reason };
2073
2297
  }
2298
+ var CANONICAL_ED25519_X = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
2299
+ function isPlainObject(value) {
2300
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2301
+ }
2302
+ function jwkEntryProblem(entry) {
2303
+ if (!isPlainObject(entry)) return "not an object";
2304
+ if (entry.kty !== "OKP") return "kty must be OKP";
2305
+ if (entry.crv !== "Ed25519") return "crv must be Ed25519";
2306
+ if (typeof entry.x !== "string" || !CANONICAL_ED25519_X.test(entry.x)) {
2307
+ return "x must be a canonical base64url 32-byte value";
2308
+ }
2309
+ if (Object.prototype.hasOwnProperty.call(entry, "d")) return "private key member d present";
2310
+ if (entry.kid !== void 0 && typeof entry.kid !== "string") return "kid must be a string";
2311
+ if (entry.use !== void 0 && entry.use !== "sig") return "use must be sig";
2312
+ if (entry.alg !== void 0 && entry.alg !== "EdDSA" && entry.alg !== "Ed25519") {
2313
+ return "alg must be EdDSA or Ed25519";
2314
+ }
2315
+ return null;
2316
+ }
2074
2317
  async function resolveJWKS(issuerUrl, options) {
2075
2318
  const cacheTtlMs = options?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
2076
2319
  const maxCacheEntries = options?.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
@@ -2160,9 +2403,9 @@ async function resolveJWKS(issuerUrl, options) {
2160
2403
  blockedUrl: jwksResult.blockedUrl
2161
2404
  };
2162
2405
  }
2163
- let jwks;
2406
+ let parsed;
2164
2407
  try {
2165
- jwks = JSON.parse(jwksResult.body);
2408
+ parsed = JSON.parse(jwksResult.body);
2166
2409
  } catch {
2167
2410
  return {
2168
2411
  ok: false,
@@ -2170,20 +2413,37 @@ async function resolveJWKS(issuerUrl, options) {
2170
2413
  message: "JWKS response is not valid JSON"
2171
2414
  };
2172
2415
  }
2173
- if (!jwks.keys || !Array.isArray(jwks.keys)) {
2416
+ const rawKeys = isPlainObject(parsed) ? parsed.keys : void 0;
2417
+ if (!Array.isArray(rawKeys)) {
2174
2418
  return {
2175
2419
  ok: false,
2176
2420
  code: "E_VERIFY_JWKS_INVALID",
2177
2421
  message: "JWKS missing required keys array"
2178
2422
  };
2179
2423
  }
2180
- if (jwks.keys.length > VERIFIER_LIMITS.maxJwksKeys) {
2424
+ if (rawKeys.length > VERIFIER_LIMITS.maxJwksKeys) {
2181
2425
  return {
2182
2426
  ok: false,
2183
2427
  code: "E_VERIFY_JWKS_TOO_MANY_KEYS",
2184
- message: `JWKS has too many keys: ${jwks.keys.length} > ${VERIFIER_LIMITS.maxJwksKeys}`
2428
+ message: `JWKS has too many keys: ${rawKeys.length} > ${VERIFIER_LIMITS.maxJwksKeys}`
2185
2429
  };
2186
2430
  }
2431
+ const validKeys = [];
2432
+ const skipped = [];
2433
+ rawKeys.forEach((entry, index) => {
2434
+ const problem = jwkEntryProblem(entry);
2435
+ if (problem === null) {
2436
+ validKeys.push(entry);
2437
+ } else {
2438
+ skipped.push(`#${index}: ${problem}`);
2439
+ }
2440
+ });
2441
+ if (skipped.length > 0) {
2442
+ console.warn(
2443
+ `[peac:jwks] Skipped ${skipped.length} invalid JWKS entr${skipped.length === 1 ? "y" : "ies"} for ${normalizedIssuer} (${skipped.join("; ")})`
2444
+ );
2445
+ }
2446
+ const jwks = { keys: validKeys };
2187
2447
  const kidReuseError = checkKidReuse(normalizedIssuer, jwks, now);
2188
2448
  if (kidReuseError) {
2189
2449
  return {
@@ -3222,9 +3482,13 @@ async function fetchIssuerJWKS(issuerOrigin) {
3222
3482
  return {
3223
3483
  jwks: result.jwks,
3224
3484
  fromCache: result.fromCache,
3225
- rawBytes: result.rawBytes
3485
+ rawBytes: result.rawBytes,
3486
+ revokedKeys: result.revokedKeys
3226
3487
  };
3227
3488
  }
3489
+ function findRevokedKey(kid, revokedKeys) {
3490
+ return revokedKeys?.find((rk) => rk.kid === kid);
3491
+ }
3228
3492
  async function verifyReceiptCore(options) {
3229
3493
  const {
3230
3494
  receipt,
@@ -3394,6 +3658,19 @@ async function verifyReceiptCore(options) {
3394
3658
  from_cache: jwksResult.fromCache,
3395
3659
  keys_count: jwksResult.jwks.keys.length
3396
3660
  });
3661
+ const revokedPinned = findRevokedKey(kid, jwksResult.revokedKeys);
3662
+ if (revokedPinned) {
3663
+ builder.fail("key.resolve", "E_REVOKED_KEY_USED", {
3664
+ kid,
3665
+ revoked_at: revokedPinned.revoked_at,
3666
+ ...revokedPinned.reason && { reason: revokedPinned.reason }
3667
+ });
3668
+ builder.failure("key_not_found", normalizedIssuer, kid);
3669
+ return {
3670
+ valid: false,
3671
+ report: includeMeta ? builder.addTimestamp().build() : builder.build()
3672
+ };
3673
+ }
3397
3674
  const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
3398
3675
  if (!jwk) {
3399
3676
  builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
@@ -3455,6 +3732,19 @@ async function verifyReceiptCore(options) {
3455
3732
  from_cache: jwksResult.fromCache,
3456
3733
  keys_count: jwksResult.jwks.keys.length
3457
3734
  });
3735
+ const revoked = findRevokedKey(kid, jwksResult.revokedKeys);
3736
+ if (revoked) {
3737
+ builder.fail("key.resolve", "E_REVOKED_KEY_USED", {
3738
+ kid,
3739
+ revoked_at: revoked.revoked_at,
3740
+ ...revoked.reason && { reason: revoked.reason }
3741
+ });
3742
+ builder.failure("key_not_found", normalizedIssuer, kid);
3743
+ return {
3744
+ valid: false,
3745
+ report: includeMeta ? builder.addTimestamp().build() : builder.build()
3746
+ };
3747
+ }
3458
3748
  const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
3459
3749
  if (!jwk) {
3460
3750
  builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {