@o3co/auth-provider-foundation 0.17.0 → 0.18.0-rc.1

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.
Files changed (293) hide show
  1. package/README.md +2 -867
  2. package/audit/README.md +38 -0
  3. package/audit/config.example +3 -0
  4. package/audit/src/contract.d.ts +37 -0
  5. package/audit/src/contract.js +29 -0
  6. package/audit/src/module.d.ts +2 -0
  7. package/audit/src/module.js +151 -0
  8. package/audit/src/section.d.ts +5 -0
  9. package/audit/src/section.js +19 -0
  10. package/audit/src/testing.d.ts +16 -0
  11. package/audit/src/testing.js +19 -0
  12. package/client-certificate/README.md +27 -0
  13. package/client-certificate/src/certificate.d.ts +9 -0
  14. package/client-certificate/src/certificate.js +40 -0
  15. package/client-certificate/src/contract.d.ts +23 -0
  16. package/client-certificate/src/contract.js +5 -0
  17. package/client-certificate/src/pem.d.ts +4 -0
  18. package/client-certificate/src/pem.js +19 -0
  19. package/client-certificate/src/settings.d.ts +13 -0
  20. package/client-certificate/src/settings.js +22 -0
  21. package/client-certificate/src/warnings.d.ts +7 -0
  22. package/client-certificate/src/warnings.js +17 -0
  23. package/client-certificate/src/xfcc.d.ts +5 -0
  24. package/client-certificate/src/xfcc.js +72 -0
  25. package/cookies/README.md +34 -0
  26. package/cookies/src/contract.d.ts +15 -0
  27. package/cookies/src/contract.js +3 -0
  28. package/cookies/src/cookies.d.ts +3 -0
  29. package/cookies/src/cookies.js +39 -0
  30. package/cookies/src/module.d.ts +2 -0
  31. package/cookies/src/module.js +7 -0
  32. package/external-jwt/README.md +38 -0
  33. package/external-jwt/src/contract.d.ts +58 -0
  34. package/external-jwt/src/contract.js +2 -0
  35. package/external-jwt/src/jwks.d.ts +20 -0
  36. package/external-jwt/src/jwks.js +61 -0
  37. package/external-jwt/src/keys.d.ts +16 -0
  38. package/external-jwt/src/keys.js +26 -0
  39. package/external-jwt/src/module.d.ts +6 -0
  40. package/external-jwt/src/module.js +21 -0
  41. package/external-jwt/src/testing.d.ts +38 -0
  42. package/external-jwt/src/testing.js +43 -0
  43. package/external-jwt/src/verifier.d.ts +10 -0
  44. package/external-jwt/src/verifier.js +147 -0
  45. package/http/README.md +39 -0
  46. package/http/config.example +5 -0
  47. package/http/src/bodies/README.md +31 -0
  48. package/http/src/bodies/body.d.ts +6 -0
  49. package/http/src/bodies/body.js +69 -0
  50. package/http/src/bodies/json.d.ts +2 -0
  51. package/http/src/bodies/json.js +35 -0
  52. package/http/src/bodies/values.d.ts +20 -0
  53. package/http/src/bodies/values.js +45 -0
  54. package/http/src/contract.d.ts +113 -0
  55. package/http/src/contract.js +23 -0
  56. package/http/src/cors/README.md +24 -0
  57. package/http/src/cors/cors.d.ts +5 -0
  58. package/http/src/cors/cors.js +35 -0
  59. package/http/src/ending/README.md +23 -0
  60. package/http/src/ending/ending.d.ts +5 -0
  61. package/http/src/ending/ending.js +27 -0
  62. package/http/src/guards/README.md +20 -0
  63. package/http/src/guards/guards.d.ts +19 -0
  64. package/http/src/guards/guards.js +31 -0
  65. package/http/src/module.d.ts +2 -0
  66. package/http/src/module.js +46 -0
  67. package/http/src/security-headers/README.md +23 -0
  68. package/http/src/security-headers/security-headers.d.ts +3 -0
  69. package/http/src/security-headers/security-headers.js +58 -0
  70. package/http/src/serving/README.md +33 -0
  71. package/http/src/serving/server.d.ts +17 -0
  72. package/http/src/serving/server.js +174 -0
  73. package/http/src/settings/README.md +16 -0
  74. package/http/src/settings/section.d.ts +21 -0
  75. package/http/src/settings/section.js +37 -0
  76. package/http/src/testing.d.ts +37 -0
  77. package/http/src/testing.js +24 -0
  78. package/http-ops/README.md +22 -0
  79. package/http-ops/src/liveness.d.ts +3 -0
  80. package/http-ops/src/liveness.js +10 -0
  81. package/http-ops/src/module.d.ts +2 -0
  82. package/http-ops/src/module.js +24 -0
  83. package/http-ops/src/readiness.d.ts +10 -0
  84. package/http-ops/src/readiness.js +54 -0
  85. package/http-ops/src/section.d.ts +4 -0
  86. package/http-ops/src/section.js +10 -0
  87. package/http-ops/src/timing.d.ts +9 -0
  88. package/http-ops/src/timing.js +30 -0
  89. package/kernel/README.md +39 -0
  90. package/kernel/config.example +4 -0
  91. package/kernel/src/assemble.d.ts +27 -0
  92. package/kernel/src/assemble.js +429 -0
  93. package/kernel/src/config.d.ts +13 -0
  94. package/kernel/src/config.js +32 -0
  95. package/kernel/src/contract.d.ts +11 -0
  96. package/kernel/src/contract.js +10 -0
  97. package/kernel/src/every.d.ts +7 -0
  98. package/kernel/src/every.js +36 -0
  99. package/kernel/src/freeze.d.ts +5 -0
  100. package/kernel/src/freeze.js +17 -0
  101. package/kernel/src/handles.d.ts +19 -0
  102. package/kernel/src/handles.js +6 -0
  103. package/kernel/src/log.d.ts +12 -0
  104. package/kernel/src/log.js +55 -0
  105. package/kernel/src/module.d.ts +67 -0
  106. package/kernel/src/module.js +18 -0
  107. package/kernel/src/readiness.d.ts +24 -0
  108. package/kernel/src/readiness.js +72 -0
  109. package/kernel/src/refusal.d.ts +19 -0
  110. package/kernel/src/refusal.js +28 -0
  111. package/kernel/src/runtime.d.ts +35 -0
  112. package/kernel/src/runtime.js +5 -0
  113. package/kernel/src/secret.d.ts +2 -0
  114. package/kernel/src/secret.js +7 -0
  115. package/kernel/src/settings.d.ts +20 -0
  116. package/kernel/src/settings.js +23 -0
  117. package/kernel/src/testing.d.ts +31 -0
  118. package/kernel/src/testing.js +99 -0
  119. package/keys/README.md +39 -0
  120. package/keys/config.example +7 -0
  121. package/keys/src/contract.d.ts +21 -0
  122. package/keys/src/contract.js +5 -0
  123. package/keys/src/jwks.d.ts +3 -0
  124. package/keys/src/jwks.js +26 -0
  125. package/keys/src/jws.d.ts +7 -0
  126. package/keys/src/jws.js +11 -0
  127. package/keys/src/module.d.ts +2 -0
  128. package/keys/src/module.js +31 -0
  129. package/keys/src/public.d.ts +21 -0
  130. package/keys/src/public.js +100 -0
  131. package/keys/src/ring.d.ts +19 -0
  132. package/keys/src/ring.js +60 -0
  133. package/keys/src/secret.d.ts +17 -0
  134. package/keys/src/secret.js +33 -0
  135. package/keys/src/section.d.ts +43 -0
  136. package/keys/src/section.js +55 -0
  137. package/keys/src/signer.d.ts +3 -0
  138. package/keys/src/signer.js +61 -0
  139. package/keys/src/testing.d.ts +28 -0
  140. package/keys/src/testing.js +12 -0
  141. package/mail/README.md +45 -0
  142. package/mail/config.example +3 -0
  143. package/mail/src/address.d.ts +7 -0
  144. package/mail/src/address.js +45 -0
  145. package/mail/src/contract.d.ts +38 -0
  146. package/mail/src/contract.js +15 -0
  147. package/mail/src/lines.d.ts +4 -0
  148. package/mail/src/lines.js +4 -0
  149. package/mail/src/module.d.ts +3 -0
  150. package/mail/src/module.js +27 -0
  151. package/mail/src/plain-text.d.ts +6 -0
  152. package/mail/src/plain-text.js +23 -0
  153. package/mail/src/prepare.d.ts +14 -0
  154. package/mail/src/prepare.js +69 -0
  155. package/mail/src/section.d.ts +4 -0
  156. package/mail/src/section.js +7 -0
  157. package/mail/src/testing.d.ts +26 -0
  158. package/mail/src/testing.js +140 -0
  159. package/metrics/README.md +45 -0
  160. package/metrics/src/build.d.ts +6 -0
  161. package/metrics/src/build.js +36 -0
  162. package/metrics/src/contract.d.ts +46 -0
  163. package/metrics/src/contract.js +8 -0
  164. package/metrics/src/gauge.d.ts +9 -0
  165. package/metrics/src/gauge.js +67 -0
  166. package/metrics/src/instrument.d.ts +50 -0
  167. package/metrics/src/instrument.js +58 -0
  168. package/metrics/src/module.d.ts +2 -0
  169. package/metrics/src/module.js +6 -0
  170. package/metrics/src/registry.d.ts +13 -0
  171. package/metrics/src/registry.js +95 -0
  172. package/metrics/src/testing.d.ts +16 -0
  173. package/metrics/src/testing.js +104 -0
  174. package/outbound/README.md +39 -0
  175. package/outbound/config.example +5 -0
  176. package/outbound/src/contract.d.ts +76 -0
  177. package/outbound/src/contract.js +36 -0
  178. package/outbound/src/module.d.ts +28 -0
  179. package/outbound/src/module.js +188 -0
  180. package/outbound/src/policy.d.ts +54 -0
  181. package/outbound/src/policy.js +187 -0
  182. package/outbound/src/transport.d.ts +21 -0
  183. package/outbound/src/transport.js +176 -0
  184. package/package.json +186 -44
  185. package/rate-limit/README.md +39 -0
  186. package/rate-limit/src/audit-events.d.ts +8 -0
  187. package/rate-limit/src/audit-events.js +8 -0
  188. package/rate-limit/src/contract.d.ts +125 -0
  189. package/rate-limit/src/contract.js +27 -0
  190. package/rate-limit/src/lockout.d.ts +13 -0
  191. package/rate-limit/src/lockout.js +195 -0
  192. package/rate-limit/src/module.d.ts +2 -0
  193. package/rate-limit/src/module.js +50 -0
  194. package/rate-limit/src/partition.d.ts +4 -0
  195. package/rate-limit/src/partition.js +4 -0
  196. package/rate-limit/src/per-source.d.ts +11 -0
  197. package/rate-limit/src/per-source.js +52 -0
  198. package/rate-limit/src/source.d.ts +2 -0
  199. package/rate-limit/src/source.js +12 -0
  200. package/rate-limit/src/testing.d.ts +2 -0
  201. package/rate-limit/src/testing.js +68 -0
  202. package/records/README.md +39 -0
  203. package/records/src/records.d.ts +100 -0
  204. package/records/src/records.js +293 -0
  205. package/records/src/shards.d.ts +10 -0
  206. package/records/src/shards.js +2 -0
  207. package/records/src/testing.d.ts +39 -0
  208. package/records/src/testing.js +137 -0
  209. package/request-source/README.md +38 -0
  210. package/request-source/src/contract.d.ts +18 -0
  211. package/request-source/src/contract.js +7 -0
  212. package/request-source/src/settings.d.ts +17 -0
  213. package/request-source/src/settings.js +46 -0
  214. package/request-source/src/source.d.ts +14 -0
  215. package/request-source/src/source.js +55 -0
  216. package/request-source/src/warnings.d.ts +4 -0
  217. package/request-source/src/warnings.js +15 -0
  218. package/seal/README.md +30 -0
  219. package/seal/config.example +8 -0
  220. package/seal/src/contract.d.ts +13 -0
  221. package/seal/src/contract.js +2 -0
  222. package/seal/src/module.d.ts +2 -0
  223. package/seal/src/module.js +57 -0
  224. package/seal/src/section.d.ts +9 -0
  225. package/seal/src/section.js +15 -0
  226. package/seal/src/testing.d.ts +11 -0
  227. package/seal/src/testing.js +5 -0
  228. package/secret-hash/README.md +33 -0
  229. package/secret-hash/src/checker.d.ts +9 -0
  230. package/secret-hash/src/checker.js +53 -0
  231. package/secret-hash/src/contract.d.ts +20 -0
  232. package/secret-hash/src/contract.js +10 -0
  233. package/secret-hash/src/hashes.d.ts +23 -0
  234. package/secret-hash/src/hashes.js +73 -0
  235. package/secret-hash/src/module.d.ts +2 -0
  236. package/secret-hash/src/module.js +17 -0
  237. package/secret-hash/src/testing.d.ts +13 -0
  238. package/secret-hash/src/testing.js +31 -0
  239. package/store/README.md +39 -0
  240. package/store/config.example +3 -0
  241. package/store/src/contract.d.ts +227 -0
  242. package/store/src/contract.js +261 -0
  243. package/store/src/memory.d.ts +11 -0
  244. package/store/src/memory.js +241 -0
  245. package/store/src/module.d.ts +2 -0
  246. package/store/src/module.js +14 -0
  247. package/store/src/section.d.ts +4 -0
  248. package/store/src/section.js +7 -0
  249. package/store/src/simulated.d.ts +41 -0
  250. package/store/src/simulated.js +216 -0
  251. package/store/src/testing.d.ts +33 -0
  252. package/store/src/testing.js +955 -0
  253. package/url-setting/README.md +29 -0
  254. package/url-setting/src/contract.d.ts +18 -0
  255. package/url-setting/src/contract.js +99 -0
  256. package/README.ja.md +0 -309
  257. package/config/reference.conf +0 -20
  258. package/dist/endpointUrl.d.mts +0 -53
  259. package/dist/endpointUrl.d.mts.map +0 -1
  260. package/dist/endpointUrl.mjs +0 -132
  261. package/dist/index.d.mts +0 -11
  262. package/dist/index.d.mts.map +0 -1
  263. package/dist/index.mjs +0 -67
  264. package/dist/mfa/HttpMfaFactorStore.d.mts +0 -60
  265. package/dist/mfa/HttpMfaFactorStore.d.mts.map +0 -1
  266. package/dist/mfa/HttpMfaFactorStore.mjs +0 -205
  267. package/dist/mfa/markEnrolled.d.mts +0 -4
  268. package/dist/mfa/markEnrolled.d.mts.map +0 -1
  269. package/dist/mfa/markEnrolled.mjs +0 -31
  270. package/dist/mfa/module.d.mts +0 -27
  271. package/dist/mfa/module.d.mts.map +0 -1
  272. package/dist/mfa/module.mjs +0 -56
  273. package/dist/mfa/section.d.mts +0 -45
  274. package/dist/mfa/section.d.mts.map +0 -1
  275. package/dist/mfa/section.mjs +0 -99
  276. package/dist/mfa/storeFailure.d.mts +0 -52
  277. package/dist/mfa/storeFailure.d.mts.map +0 -1
  278. package/dist/mfa/storeFailure.mjs +0 -107
  279. package/dist/repositories/HttpUserRepository.d.mts +0 -130
  280. package/dist/repositories/HttpUserRepository.d.mts.map +0 -1
  281. package/dist/repositories/HttpUserRepository.mjs +0 -376
  282. package/dist/repositories/storeErrors.d.mts +0 -72
  283. package/dist/repositories/storeErrors.d.mts.map +0 -1
  284. package/dist/repositories/storeErrors.mjs +0 -183
  285. package/dist/repositories/wwwAuthenticate.d.mts +0 -10
  286. package/dist/repositories/wwwAuthenticate.d.mts.map +0 -1
  287. package/dist/repositories/wwwAuthenticate.mjs +0 -54
  288. package/dist/storeTransport.d.mts +0 -97
  289. package/dist/storeTransport.d.mts.map +0 -1
  290. package/dist/storeTransport.mjs +0 -290
  291. package/dist/testing/index.d.mts +0 -28
  292. package/dist/testing/index.d.mts.map +0 -1
  293. package/dist/testing/index.mjs +0 -58
@@ -0,0 +1,7 @@
1
+ /** A segment as its bytes encode: base64url without padding, and no bits a decoder drops. */
2
+ export declare const canonical: (segment: string) => boolean;
3
+ /**
4
+ * Whether a compact JWS is written in its one form. A decoder drops a last character's spare bits,
5
+ * so without this one token has many strings, and anything keyed by the string is slipped past.
6
+ */
7
+ export declare const isCanonicalJws: (jws: string) => boolean;
@@ -0,0 +1,11 @@
1
+ /** A segment as its bytes encode: base64url without padding, and no bits a decoder drops. */
2
+ export const canonical = (segment) => /^[A-Za-z0-9_-]*$/.test(segment) &&
3
+ Buffer.from(segment, "base64url").toString("base64url") === segment;
4
+ /**
5
+ * Whether a compact JWS is written in its one form. A decoder drops a last character's spare bits,
6
+ * so without this one token has many strings, and anything keyed by the string is slipped past.
7
+ */
8
+ export const isCanonicalJws = (jws) => {
9
+ const segments = jws.split(".");
10
+ return segments.length === 3 && segments.every(canonical);
11
+ };
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@o3co/auth-provider-foundation/kernel").Module;
2
+ export default _default;
@@ -0,0 +1,31 @@
1
+ import { routes } from "@o3co/auth-provider-foundation/http";
2
+ import { contract, defineModule } from "@o3co/auth-provider-foundation/kernel";
3
+ import { keys } from "./contract.js";
4
+ import { jwksResponse } from "./jwks.js";
5
+ import { readRing } from "./ring.js";
6
+ import { section } from "./section.js";
7
+ import { signer } from "./signer.js";
8
+ const ring = contract("keys.ring");
9
+ const keySetPath = "/.well-known/jwks.json";
10
+ export default defineModule({ name: "keys", section }, (m) => [
11
+ m.provide(ring).by((c) => {
12
+ const read = readRing(c.section);
13
+ if ("unreadable" in read)
14
+ return c.refuse(`keys.${read.unreadable}`, "not readable as a key for alg");
15
+ return read;
16
+ }),
17
+ m.provide(keys).by(async (c) => ({
18
+ ...signer(await c.get(ring), c.clock.now),
19
+ keySetPath: () => keySetPath,
20
+ })),
21
+ m.contribute(routes).by(async (c) => {
22
+ const current = await c.get(ring);
23
+ return {
24
+ method: "GET",
25
+ path: keySetPath,
26
+ // Public keys a page may read from any listed origin.
27
+ cors: {},
28
+ handle: (request) => jwksResponse(current, c.clock.now(), request),
29
+ };
30
+ }),
31
+ ]);
@@ -0,0 +1,21 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ export type Jwk = Readonly<Record<string, unknown>>;
3
+ /** The asymmetric algorithms a public key may verify under. */
4
+ export declare const verifyAlgorithms: readonly string[];
5
+ /** A JWK the platform reads as a public key, kept as given; none with a private member, a `kid` that is no string, or a key member not in its one form. */
6
+ export declare function publicJwk(jwk: Jwk): {
7
+ readonly jwk: Jwk;
8
+ readonly key: KeyObject;
9
+ } | undefined;
10
+ /** A public key written as PEM; none for anything else, a private key above all. */
11
+ export declare function publicPem(pem: string): KeyObject | undefined;
12
+ /**
13
+ * Whether a JWK may verify: its `use` is `sig` and its `key_ops` hold `verify` where named, and its
14
+ * kind and curve fit `alg` (and its own `alg` is `alg`), or, with no `alg`, any algorithm here.
15
+ */
16
+ export declare function verifiesUnder(jwk: Jwk, alg?: string): boolean;
17
+ /**
18
+ * The JWK rebuilt from its type's public members alone; none if it holds any other member but
19
+ * those describing it, or a member that is no string. Not read by the platform.
20
+ */
21
+ export declare function publicMembers(jwk: unknown): Readonly<Record<string, string>> | undefined;
@@ -0,0 +1,100 @@
1
+ import { createPrivateKey, createPublicKey } from "node:crypto";
2
+ import { canonical } from "./jws.js";
3
+ /** The kind of key each asymmetric algorithm verifies with. */
4
+ const fits = {
5
+ ...Object.fromEntries(["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"].map((alg) => [
6
+ alg,
7
+ (jwk) => jwk.kty === "RSA",
8
+ ])),
9
+ ES256: (jwk) => jwk.kty === "EC" && jwk.crv === "P-256",
10
+ ES384: (jwk) => jwk.kty === "EC" && jwk.crv === "P-384",
11
+ ES512: (jwk) => jwk.kty === "EC" && jwk.crv === "P-521",
12
+ EdDSA: (jwk) => jwk.kty === "OKP" && (jwk.crv === "Ed25519" || jwk.crv === "Ed448"),
13
+ };
14
+ /** The asymmetric algorithms a public key may verify under. */
15
+ export const verifyAlgorithms = Object.freeze(Object.keys(fits));
16
+ /** The members of each public key type. A Map, so no inherited name is a type. */
17
+ const membersOf = new Map([
18
+ ["EC", ["kty", "crv", "x", "y"]],
19
+ ["OKP", ["kty", "crv", "x"]],
20
+ ["RSA", ["kty", "n", "e"]],
21
+ ]);
22
+ /** Members describing a key that carry no key material. */
23
+ const describing = new Set(["kid", "alg", "use", "key_ops"]);
24
+ /** Whether each key member is base64url as its bytes encode, so one key has one JWK. */
25
+ const inOneForm = (jwk) => (membersOf.get(jwk.kty) ?? [])
26
+ .filter((member) => member !== "kty" && member !== "crv")
27
+ .every((member) => typeof jwk[member] !== "string" || canonical(jwk[member]));
28
+ const privateMembers = ["d", "p", "q", "dp", "dq", "qi", "k", "oth"];
29
+ /** A JWK the platform reads as a public key, kept as given; none with a private member, a `kid` that is no string, or a key member not in its one form. */
30
+ export function publicJwk(jwk) {
31
+ if (privateMembers.some((member) => Object.hasOwn(jwk, member)))
32
+ return undefined;
33
+ if (jwk.kid !== undefined && typeof jwk.kid !== "string")
34
+ return undefined;
35
+ if (!inOneForm(jwk))
36
+ return undefined;
37
+ try {
38
+ return { jwk, key: createPublicKey({ key: jwk, format: "jwk" }) };
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function isPrivate(pem) {
45
+ try {
46
+ createPrivateKey(pem);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ /** A public key written as PEM; none for anything else, a private key above all. */
54
+ export function publicPem(pem) {
55
+ // The platform reads a public key out of a private one, so a private key is refused first.
56
+ if (isPrivate(pem))
57
+ return undefined;
58
+ try {
59
+ return createPublicKey(pem);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ /**
66
+ * Whether a JWK may verify: its `use` is `sig` and its `key_ops` hold `verify` where named, and its
67
+ * kind and curve fit `alg` (and its own `alg` is `alg`), or, with no `alg`, any algorithm here.
68
+ */
69
+ export function verifiesUnder(jwk, alg) {
70
+ if (jwk.use !== undefined && jwk.use !== "sig")
71
+ return false;
72
+ if (jwk.key_ops !== undefined && !(Array.isArray(jwk.key_ops) && jwk.key_ops.includes("verify")))
73
+ return false;
74
+ if (alg === undefined)
75
+ return Object.values(fits).some((fit) => fit(jwk));
76
+ const fit = Object.hasOwn(fits, alg) ? fits[alg] : undefined;
77
+ return (fit?.(jwk) ?? false) && (jwk.alg === undefined || jwk.alg === alg);
78
+ }
79
+ /**
80
+ * The JWK rebuilt from its type's public members alone; none if it holds any other member but
81
+ * those describing it, or a member that is no string. Not read by the platform.
82
+ */
83
+ export function publicMembers(jwk) {
84
+ if (typeof jwk !== "object" || jwk === null || Array.isArray(jwk))
85
+ return undefined;
86
+ const given = jwk;
87
+ const members = membersOf.get(given.kty);
88
+ if (!members)
89
+ return undefined;
90
+ if (Object.keys(given).some((m) => !members.includes(m) && !describing.has(m)))
91
+ return undefined;
92
+ const rebuilt = {};
93
+ for (const m of members) {
94
+ const value = given[m];
95
+ if (typeof value !== "string")
96
+ return undefined;
97
+ rebuilt[m] = value;
98
+ }
99
+ return inOneForm(rebuilt) ? rebuilt : undefined;
100
+ }
@@ -0,0 +1,19 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ import type { Section } from "./section.js";
3
+ export interface RingKey {
4
+ readonly kid: string;
5
+ readonly alg: string;
6
+ readonly key: KeyObject;
7
+ readonly expiresAt?: number;
8
+ }
9
+ export interface Ring {
10
+ readonly signing: RingKey;
11
+ /** Keys safe to publish: the signing key's public half (unless HS256), the previous keys and the next. */
12
+ readonly publicKeys: readonly RingKey[];
13
+ }
14
+ /** True once the key's expiry has passed; reads the clock only for keys that expire. */
15
+ export declare function expired(key: RingKey, now: () => number): boolean;
16
+ /** Returns the config path that cannot be read as a key of its alg, or the ring. */
17
+ export declare function readRing(section: Section): Ring | {
18
+ readonly unreadable: string;
19
+ };
@@ -0,0 +1,60 @@
1
+ import { createPrivateKey, createPublicKey, createSecretKey } from "node:crypto";
2
+ import { bytesOf } from "./secret.js";
3
+ const keyTypes = {
4
+ EdDSA: (key) => key.asymmetricKeyType === "ed25519",
5
+ ES256: (key) => key.asymmetricKeyType === "ec" && key.asymmetricKeyDetails?.namedCurve === "prime256v1",
6
+ RS256: (key) => key.asymmetricKeyType === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) >= 2048,
7
+ };
8
+ /** True once the key's expiry has passed; reads the clock only for keys that expire. */
9
+ export function expired(key, now) {
10
+ return key.expiresAt !== undefined && key.expiresAt <= now();
11
+ }
12
+ function parsed(read) {
13
+ try {
14
+ return read();
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function signingKey(signing) {
21
+ if (signing.alg === "HS256") {
22
+ return {
23
+ kid: signing.kid,
24
+ alg: "HS256",
25
+ key: createSecretKey(bytesOf(signing.secret, { hex: true }) ?? Buffer.alloc(0)),
26
+ };
27
+ }
28
+ // createPrivateKey refuses a public key, so what it reads is always private.
29
+ const key = parsed(() => createPrivateKey(signing["private-key"]));
30
+ if (!key || !keyTypes[signing.alg](key))
31
+ return undefined;
32
+ return { kid: signing.kid, alg: signing.alg, key };
33
+ }
34
+ /** Returns the config path that cannot be read as a key of its alg, or the ring. */
35
+ export function readRing(section) {
36
+ const signing = signingKey(section.signing);
37
+ if (!signing)
38
+ return { unreadable: "signing.private-key" };
39
+ const publicKeys = signing.alg === "HS256" ? [] : [{ ...signing, key: createPublicKey(signing.key) }];
40
+ for (const [i, entry] of section.previous.entries()) {
41
+ // The provider's own previous key: a private PEM is taken too, for its public half.
42
+ const key = parsed(() => createPublicKey(entry["public-key"]));
43
+ if (!key || !keyTypes[entry.alg](key))
44
+ return { unreadable: `previous.${i}.public-key` };
45
+ publicKeys.push({
46
+ kid: entry.kid,
47
+ alg: entry.alg,
48
+ key,
49
+ expiresAt: Date.parse(entry["expires-at"]),
50
+ });
51
+ }
52
+ const coming = section.next;
53
+ if (coming !== undefined) {
54
+ const key = parsed(() => createPublicKey(coming["public-key"]));
55
+ if (!key || !keyTypes[coming.alg](key))
56
+ return { unreadable: "next.public-key" };
57
+ publicKeys.push({ kid: coming.kid, alg: coming.alg, key });
58
+ }
59
+ return { signing, publicKeys };
60
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ /** A secret setting's bytes: hex where `hex` is taken and the value is whole bytes of hex, else strict base64; none when neither. */
3
+ export declare function bytesOf(value: string, { hex }?: {
4
+ hex?: boolean | undefined;
5
+ }): Buffer | undefined;
6
+ /**
7
+ * The setting of a secret's bytes: strict base64 (or hex, where `hex` is taken) of `exactly` or
8
+ * `atLeast` that many bytes, of at least 8 byte values, never a `REPLACE-` sample. A refusal names
9
+ * the rule, never the value.
10
+ */
11
+ export declare function secretBytes(size: {
12
+ readonly exactly: number;
13
+ } | {
14
+ readonly atLeast: number;
15
+ }, { hex }?: {
16
+ hex?: boolean | undefined;
17
+ }): z.ZodString;
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ /** Standard base64 alone, padded: a lax decoder skips other characters and reads what is left. */
3
+ const strictBase64 = /^(?:[A-Za-z0-9+/]{4})+(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$|^(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/;
4
+ /** A key of fewer byte values is weak (all zero, a short repeated pattern); a random one is, with odds under 1e-36. */
5
+ const fewestByteValues = 8;
6
+ /** A secret setting's bytes: hex where `hex` is taken and the value is whole bytes of hex, else strict base64; none when neither. */
7
+ export function bytesOf(value, { hex = false } = {}) {
8
+ if (hex && /^(?:[0-9a-fA-F]{2})+$/.test(value))
9
+ return Buffer.from(value, "hex");
10
+ return strictBase64.test(value) ? Buffer.from(value, "base64") : undefined;
11
+ }
12
+ /**
13
+ * The setting of a secret's bytes: strict base64 (or hex, where `hex` is taken) of `exactly` or
14
+ * `atLeast` that many bytes, of at least 8 byte values, never a `REPLACE-` sample. A refusal names
15
+ * the rule, never the value.
16
+ */
17
+ export function secretBytes(size, { hex = false } = {}) {
18
+ return z.string().superRefine((value, ctx) => {
19
+ const refuse = (message) => ctx.addIssue({ code: "custom", message });
20
+ if (value.startsWith("REPLACE-"))
21
+ return refuse("a sample left in place: REPLACE- is never a secret");
22
+ const bytes = bytesOf(value, { hex });
23
+ if (!bytes)
24
+ return refuse(hex ? "not hex or base64" : "not base64");
25
+ if ("exactly" in size && bytes.length !== size.exactly)
26
+ return refuse(`${size.exactly} bytes`);
27
+ if ("atLeast" in size && bytes.length < size.atLeast)
28
+ return refuse(`at least ${size.atLeast} bytes`);
29
+ if (new Set(bytes).size < fewestByteValues) {
30
+ return refuse(`a weak key: fewer than ${fewestByteValues} byte values`);
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ declare const asymmetric: z.ZodEnum<{
3
+ RS256: "RS256";
4
+ ES256: "ES256";
5
+ EdDSA: "EdDSA";
6
+ }>;
7
+ export type AsymmetricAlg = z.output<typeof asymmetric>;
8
+ export declare const section: z.ZodObject<{
9
+ signing: z.ZodDiscriminatedUnion<[z.ZodObject<{
10
+ kid: z.ZodString;
11
+ alg: z.ZodEnum<{
12
+ RS256: "RS256";
13
+ ES256: "ES256";
14
+ EdDSA: "EdDSA";
15
+ }>;
16
+ "private-key": z.ZodString;
17
+ }, z.core.$strict>, z.ZodObject<{
18
+ kid: z.ZodString;
19
+ alg: z.ZodLiteral<"HS256">;
20
+ secret: z.ZodString;
21
+ }, z.core.$strict>], "alg">;
22
+ next: z.ZodOptional<z.ZodObject<{
23
+ kid: z.ZodString;
24
+ alg: z.ZodEnum<{
25
+ RS256: "RS256";
26
+ ES256: "ES256";
27
+ EdDSA: "EdDSA";
28
+ }>;
29
+ "public-key": z.ZodString;
30
+ }, z.core.$strict>>;
31
+ previous: z.ZodDefault<z.ZodArray<z.ZodObject<{
32
+ kid: z.ZodString;
33
+ alg: z.ZodEnum<{
34
+ RS256: "RS256";
35
+ ES256: "ES256";
36
+ EdDSA: "EdDSA";
37
+ }>;
38
+ "public-key": z.ZodString;
39
+ "expires-at": z.ZodISODateTime;
40
+ }, z.core.$strict>>>;
41
+ }, z.core.$strict>;
42
+ export type Section = z.output<typeof section>;
43
+ export {};
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ import { secretBytes } from "./secret.js";
3
+ const kid = z
4
+ .string()
5
+ .regex(/^[A-Za-z0-9._~-]{1,128}$/, "kid is 1 to 128 characters of letters, digits and . _ ~ -");
6
+ const asymmetric = z.enum(["EdDSA", "ES256", "RS256"]);
7
+ /** Written as PEM, so a placeholder or a key in another form is refused here, before it is read. */
8
+ const privatePem = z.string().regex(/^\s*-----BEGIN [A-Z0-9 ]+-----/, "a PEM");
9
+ const signingKid = kid.describe("The key id tokens signed with this key carry, unique in the ring.");
10
+ const signingAlg = "The algorithm the provider signs with.";
11
+ const signing = z.discriminatedUnion("alg", [
12
+ z.strictObject({
13
+ kid: signingKid,
14
+ alg: asymmetric.describe(signingAlg),
15
+ "private-key": privatePem.describe("The signing key, as a PKCS8 PEM."),
16
+ }),
17
+ z.strictObject({
18
+ kid: signingKid,
19
+ alg: z.literal("HS256").describe(signingAlg),
20
+ secret: secretBytes({ atLeast: 32 }, { hex: true }).describe("The HMAC secret for HS256: at least 32 bytes, in hex or base64, of at least 8 byte values."),
21
+ }),
22
+ ]);
23
+ const previous = z.strictObject({
24
+ kid: kid.describe("The key id of a past signing key, unique in the ring."),
25
+ alg: asymmetric.describe("The algorithm the past key signed with."),
26
+ "public-key": z
27
+ .string()
28
+ .min(1)
29
+ .describe("The past key's public half, as PEM; a private PEM is taken too."),
30
+ "expires-at": z.iso
31
+ .datetime({ offset: true })
32
+ .describe("When tokens signed by the past key stop being accepted."),
33
+ });
34
+ const next = z.strictObject({
35
+ kid: kid.describe("The key id of the key that signs next, unique in the ring."),
36
+ alg: asymmetric.describe("The algorithm the next key will sign with."),
37
+ "public-key": z
38
+ .string()
39
+ .min(1)
40
+ .describe("The next key's public half, as PEM; a private PEM is taken too."),
41
+ });
42
+ export const section = z
43
+ .strictObject({
44
+ signing: signing.describe("The key the provider signs with now."),
45
+ next: next
46
+ .optional()
47
+ .describe("The key that signs next: published and verifying now, never signing, so verifiers know it before it signs."),
48
+ previous: z
49
+ .array(previous)
50
+ .default([])
51
+ .describe("Past signing keys that still verify tokens until each one's `expires-at`."),
52
+ })
53
+ .refine((s) => new Set([s.signing.kid, ...s.previous.map((p) => p.kid)]).size === 1 + s.previous.length, { message: "duplicate kid", path: ["previous"] })
54
+ .refine((s) => s.next === undefined ||
55
+ ![s.signing.kid, ...s.previous.map((p) => p.kid)].includes(s.next.kid), { message: "duplicate kid", path: ["next"] });
@@ -0,0 +1,3 @@
1
+ import type { Keys } from "./contract.js";
2
+ import { type Ring } from "./ring.js";
3
+ export declare function signer(ring: Ring, now: () => number): Omit<Keys, "keySetPath">;
@@ -0,0 +1,61 @@
1
+ import { CompactSign, compactVerify, decodeProtectedHeader, } from "jose";
2
+ import { isCanonicalJws } from "./jws.js";
3
+ import { expired } from "./ring.js";
4
+ const encoder = new TextEncoder();
5
+ const decoder = new TextDecoder();
6
+ function headerOf(token) {
7
+ try {
8
+ return decodeProtectedHeader(token);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ async function verifiedPayload(token, key) {
15
+ try {
16
+ return (await compactVerify(token, key.key, { algorithms: [key.alg] })).payload;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ function claimsOf(payload) {
23
+ try {
24
+ return JSON.parse(decoder.decode(payload));
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ export function signer(ring, now) {
31
+ // An HS256 secret verifies its own tokens; an asymmetric signing key verifies through its public half.
32
+ const verifiers = ring.signing.alg === "HS256" ? [ring.signing, ...ring.publicKeys] : ring.publicKeys;
33
+ const byKid = new Map(verifiers.map((k) => [k.kid, k]));
34
+ return {
35
+ asymmetric: () => ring.signing.alg !== "HS256",
36
+ algorithm: () => ring.signing.alg,
37
+ sign: (claims, typ) => new CompactSign(encoder.encode(JSON.stringify(claims)))
38
+ .setProtectedHeader({ alg: ring.signing.alg, typ, kid: ring.signing.kid })
39
+ .sign(ring.signing.key),
40
+ verify: async (token, typ) => {
41
+ const header = isCanonicalJws(token) ? headerOf(token) : undefined;
42
+ if (!header)
43
+ return { kind: "invalid", reason: "malformed" };
44
+ if (header.typ !== typ)
45
+ return { kind: "invalid", reason: "typ" };
46
+ const key = typeof header.kid === "string" ? byKid.get(header.kid) : undefined;
47
+ if (!key)
48
+ return { kind: "invalid", reason: "kid_unknown" };
49
+ if (expired(key, now))
50
+ return { kind: "invalid", reason: "kid_expired" };
51
+ const payload = await verifiedPayload(token, key);
52
+ if (!payload)
53
+ return { kind: "invalid", reason: "signature" };
54
+ const claims = claimsOf(payload);
55
+ // Only a key holder can sign a payload that is not JSON; it is answered as a bad signature.
56
+ if (claims === undefined)
57
+ return { kind: "invalid", reason: "signature" };
58
+ return { kind: "valid", claims: Object.freeze(claims) };
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,28 @@
1
+ import type { z } from "zod";
2
+ import type { section } from "./section.js";
3
+ export type KeysSettings = z.input<typeof section>;
4
+ /** The keys part of a test's configuration: a fresh Ed25519 signing key, unless the test sets one. */
5
+ export declare function keysConfig(settings?: Partial<KeysSettings>): {
6
+ keys: {
7
+ signing: {
8
+ kid: string;
9
+ alg: "RS256" | "ES256" | "EdDSA";
10
+ "private-key": string;
11
+ } | {
12
+ kid: string;
13
+ alg: "HS256";
14
+ secret: string;
15
+ };
16
+ next?: {
17
+ kid: string;
18
+ alg: "RS256" | "ES256" | "EdDSA";
19
+ "public-key": string;
20
+ } | undefined;
21
+ previous?: {
22
+ kid: string;
23
+ alg: "RS256" | "ES256" | "EdDSA";
24
+ "public-key": string;
25
+ "expires-at": string;
26
+ }[] | undefined;
27
+ };
28
+ };
@@ -0,0 +1,12 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ /** The keys part of a test's configuration: a fresh Ed25519 signing key, unless the test sets one. */
3
+ export function keysConfig(settings = {}) {
4
+ const signing = settings.signing ?? {
5
+ kid: "test",
6
+ alg: "EdDSA",
7
+ "private-key": generateKeyPairSync("ed25519")
8
+ .privateKey.export({ format: "pem", type: "pkcs8" })
9
+ .toString(),
10
+ };
11
+ return { keys: { ...settings, signing } };
12
+ }
package/mail/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # mail
2
+
3
+ ## Responsibilities
4
+ - Owns the contract for sending mail: the sending side gives a purpose, one address, values, and optionally a locale and an expiry.
5
+ - Collects the purposes sending sides declare and the templates (wording per purpose); refuses boot when a declared purpose has no template.
6
+ - Prepares each message for every sender: copies and checks it, renders it, checks the rendered result.
7
+ - Holds the address rule, a plain-text template part, and the logging-only sender.
8
+
9
+ ## Not here
10
+ - The list of purposes and what they mean: the sending side's vocabulary.
11
+ - Any wording: templates, contributed apart.
12
+ - Delivery over a network, and its limits: the sender (an adapter).
13
+
14
+ ## Offers
15
+ - `mail` contract: send (purpose, to, values, locale, expiry) → sent / limited.
16
+ - Purpose declarations (`mailPurposes`) and templates (`mailTemplates`: values, locale → subject, text), both keyed by purpose. A deployment changes the wording by replacing the module that contributes the template.
17
+ - `isAddress`: whether a string is one address mail takes; a sender checks its own configured addresses by it.
18
+ - `canonicalAddress`: an address in one form to compare by, the domain in IDNA's ASCII form, lowercase, the local part as given.
19
+ - `plainText`: one wording (English by default) for every locale, `{name}` replaced by that value as it is.
20
+ - `preparer`, for senders: called at build, it gives the function that turns a message into what may be sent, or refuses.
21
+ - Composition definition: the logging-only sender. It logs the purpose, a per-process keyed hash of the address, and the value names.
22
+ - Operator section (logging-only sender): `accept-unsent`, default false; set true to accept that nothing is sent.
23
+ - Contract tests, the module they compose with, and test configuration builders (`./testing`). A sender's harness may make its remote side answer with a limit or fail, saying words no failure may carry.
24
+
25
+ ## Invariants
26
+ - One address per send; anything else (a list, a line break, a display name) is a TypeError.
27
+ - An address is at most 254 bytes in UTF-8 and holds no space, control or format character, route operator (`%`, `!`) or encoded word, else a TypeError. Letters beyond ASCII are allowed.
28
+ - Its domain reads one way, else a TypeError: IDNA's ASCII form of it is letters, digits, hyphens and dots, each `xn--` label in it decodes to other letters and encodes back to itself, and the domain is that form or its Unicode form, case aside.
29
+ - Values that are not a plain object of strings, a locale that is not a string, and an undeclared purpose are TypeErrors.
30
+ - A template whose subject is not one line, or whose text holds control characters other than line feed and tab, is a TypeError.
31
+ - A message whose expiry has come is unavailable and is not sent.
32
+ - Failures are unavailable or limited, never with the remote side's words.
33
+ - The plain-text part refuses a missing value and a value that breaks a line, and never reads a placeholder inside a value.
34
+ - The logging-only sender refuses boot unless `accept-unsent` is set, is not safe on multiple instances, and logs no address and no value.
35
+
36
+ ## Data
37
+ - None stored; the logging-only sender's hash key lives for the process.
38
+
39
+ ## Depends on
40
+ - `kernel`
41
+
42
+ ## Vocabulary
43
+ - purpose: a name the sending side gives a kind of message; templates are chosen by it.
44
+ - template: the wording of one purpose, contributed apart from the sending side so a deployment can replace it.
45
+ - sender: the implementation of the contract that delivers.
@@ -0,0 +1,3 @@
1
+ mail {
2
+ accept-unsent = true
3
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Whether `to` is one address mail takes: at most 254 bytes in UTF-8 (RFC 5321), letters beyond
3
+ * ASCII allowed. A sender checks its own configured addresses by it too.
4
+ */
5
+ export declare function isAddress(to: unknown): to is string;
6
+ /** One form for comparing addresses: the domain in IDNA's ASCII form, lowercase, the local part as given; none for a string that is no address. */
7
+ export declare function canonicalAddress(to: string): string | undefined;
@@ -0,0 +1,45 @@
1
+ import { domainToASCII, domainToUnicode } from "node:url";
2
+ // No space, control or format character, and no route operator (`%`, `!`).
3
+ const part = String.raw `[^\s\p{Cc}\p{Cf}@,;:<>()[\]"\\%!]+`;
4
+ const address = new RegExp(`^${part}@${part}$`, "u");
5
+ /** An encoded word (RFC 2047), which a mail reader decodes into other text. */
6
+ const encodedWord = /=\?.*\?=/;
7
+ /** Letters, digits and hyphens, in labels between dots. */
8
+ const ldh = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
9
+ /** An `xn--` label decodes to other letters and encodes back to itself; some Node versions pass one that does not. */
10
+ function aLabelHolds(label) {
11
+ if (!label.startsWith("xn--"))
12
+ return true;
13
+ const unicode = domainToUnicode(label);
14
+ return unicode !== label && domainToASCII(unicode) === label;
15
+ }
16
+ /**
17
+ * A domain read one way: its IDNA ASCII form is LDH with every A-label holding, and it is that form
18
+ * or its Unicode form, case aside. IDNA drops what follows `/`, `?` or `#` and maps full-width
19
+ * letters, so sending the ASCII form would otherwise reach a domain other than the one checked.
20
+ */
21
+ function readsAsItself(domain) {
22
+ const ascii = domainToASCII(domain);
23
+ const own = domain.toLowerCase();
24
+ if (!ldh.test(ascii) || !ascii.split(".").every(aLabelHolds))
25
+ return false;
26
+ return own === ascii || own === domainToUnicode(ascii);
27
+ }
28
+ /**
29
+ * Whether `to` is one address mail takes: at most 254 bytes in UTF-8 (RFC 5321), letters beyond
30
+ * ASCII allowed. A sender checks its own configured addresses by it too.
31
+ */
32
+ export function isAddress(to) {
33
+ if (typeof to !== "string" || !to.isWellFormed())
34
+ return false;
35
+ if (Buffer.byteLength(to) > 254 || !address.test(to) || encodedWord.test(to))
36
+ return false;
37
+ return readsAsItself(to.slice(to.indexOf("@") + 1));
38
+ }
39
+ /** One form for comparing addresses: the domain in IDNA's ASCII form, lowercase, the local part as given; none for a string that is no address. */
40
+ export function canonicalAddress(to) {
41
+ if (!isAddress(to))
42
+ return undefined;
43
+ const at = to.lastIndexOf("@");
44
+ return `${to.slice(0, at)}@${domainToASCII(to.slice(at + 1))}`;
45
+ }