was-teaching-server 0.9.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 (295) hide show
  1. package/CHANGELOG.md +1432 -0
  2. package/LICENSE +661 -0
  3. package/README.md +305 -0
  4. package/common/css/materialize.1.0.0.min.css +13 -0
  5. package/dist/auth-header-hooks.d.ts +84 -0
  6. package/dist/auth-header-hooks.d.ts.map +1 -0
  7. package/dist/auth-header-hooks.js +136 -0
  8. package/dist/auth-header-hooks.js.map +1 -0
  9. package/dist/authorize.d.ts +45 -0
  10. package/dist/authorize.d.ts.map +1 -0
  11. package/dist/authorize.js +76 -0
  12. package/dist/authorize.js.map +1 -0
  13. package/dist/backends/filesystem.d.ts +1179 -0
  14. package/dist/backends/filesystem.d.ts.map +1 -0
  15. package/dist/backends/filesystem.js +2727 -0
  16. package/dist/backends/filesystem.js.map +1 -0
  17. package/dist/backends/postgres.d.ts +833 -0
  18. package/dist/backends/postgres.d.ts.map +1 -0
  19. package/dist/backends/postgres.js +2164 -0
  20. package/dist/backends/postgres.js.map +1 -0
  21. package/dist/backends/postgresSchema.d.ts +36 -0
  22. package/dist/backends/postgresSchema.d.ts.map +1 -0
  23. package/dist/backends/postgresSchema.js +203 -0
  24. package/dist/backends/postgresSchema.js.map +1 -0
  25. package/dist/config.default.d.ts +360 -0
  26. package/dist/config.default.d.ts.map +1 -0
  27. package/dist/config.default.js +493 -0
  28. package/dist/config.default.js.map +1 -0
  29. package/dist/corsProxy.d.ts +45 -0
  30. package/dist/corsProxy.d.ts.map +1 -0
  31. package/dist/corsProxy.js +278 -0
  32. package/dist/corsProxy.js.map +1 -0
  33. package/dist/digest.d.ts +45 -0
  34. package/dist/digest.d.ts.map +1 -0
  35. package/dist/digest.js +174 -0
  36. package/dist/digest.js.map +1 -0
  37. package/dist/errors.d.ts +654 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +960 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +21 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/atomicFile.d.ts +61 -0
  46. package/dist/lib/atomicFile.d.ts.map +1 -0
  47. package/dist/lib/atomicFile.js +150 -0
  48. package/dist/lib/atomicFile.js.map +1 -0
  49. package/dist/lib/backendRegistry.d.ts +59 -0
  50. package/dist/lib/backendRegistry.d.ts.map +1 -0
  51. package/dist/lib/backendRegistry.js +92 -0
  52. package/dist/lib/backendRegistry.js.map +1 -0
  53. package/dist/lib/backendUsage.d.ts +28 -0
  54. package/dist/lib/backendUsage.d.ts.map +1 -0
  55. package/dist/lib/backendUsage.js +55 -0
  56. package/dist/lib/backendUsage.js.map +1 -0
  57. package/dist/lib/backends.d.ts +142 -0
  58. package/dist/lib/backends.d.ts.map +1 -0
  59. package/dist/lib/backends.js +272 -0
  60. package/dist/lib/backends.js.map +1 -0
  61. package/dist/lib/blindedIndex.d.ts +156 -0
  62. package/dist/lib/blindedIndex.d.ts.map +1 -0
  63. package/dist/lib/blindedIndex.js +338 -0
  64. package/dist/lib/blindedIndex.js.map +1 -0
  65. package/dist/lib/collectionDescription.d.ts +33 -0
  66. package/dist/lib/collectionDescription.d.ts.map +1 -0
  67. package/dist/lib/collectionDescription.js +21 -0
  68. package/dist/lib/collectionDescription.js.map +1 -0
  69. package/dist/lib/cursor.d.ts +18 -0
  70. package/dist/lib/cursor.d.ts.map +1 -0
  71. package/dist/lib/cursor.js +50 -0
  72. package/dist/lib/cursor.js.map +1 -0
  73. package/dist/lib/edvEnvelope.d.ts +50 -0
  74. package/dist/lib/edvEnvelope.d.ts.map +1 -0
  75. package/dist/lib/edvEnvelope.js +106 -0
  76. package/dist/lib/edvEnvelope.js.map +1 -0
  77. package/dist/lib/encryption.d.ts +192 -0
  78. package/dist/lib/encryption.d.ts.map +1 -0
  79. package/dist/lib/encryption.js +383 -0
  80. package/dist/lib/encryption.js.map +1 -0
  81. package/dist/lib/etag.d.ts +33 -0
  82. package/dist/lib/etag.d.ts.map +1 -0
  83. package/dist/lib/etag.js +36 -0
  84. package/dist/lib/etag.js.map +1 -0
  85. package/dist/lib/exportManifest.d.ts +29 -0
  86. package/dist/lib/exportManifest.d.ts.map +1 -0
  87. package/dist/lib/exportManifest.js +77 -0
  88. package/dist/lib/exportManifest.js.map +1 -0
  89. package/dist/lib/importTar.d.ts +81 -0
  90. package/dist/lib/importTar.d.ts.map +1 -0
  91. package/dist/lib/importTar.js +301 -0
  92. package/dist/lib/importTar.js.map +1 -0
  93. package/dist/lib/isJson.d.ts +16 -0
  94. package/dist/lib/isJson.d.ts.map +1 -0
  95. package/dist/lib/isJson.js +17 -0
  96. package/dist/lib/isJson.js.map +1 -0
  97. package/dist/lib/keyEpoch.d.ts +38 -0
  98. package/dist/lib/keyEpoch.d.ts.map +1 -0
  99. package/dist/lib/keyEpoch.js +75 -0
  100. package/dist/lib/keyEpoch.js.map +1 -0
  101. package/dist/lib/keyedMutex.d.ts +28 -0
  102. package/dist/lib/keyedMutex.d.ts.map +1 -0
  103. package/dist/lib/keyedMutex.js +45 -0
  104. package/dist/lib/keyedMutex.js.map +1 -0
  105. package/dist/lib/kmsModule.d.ts +62 -0
  106. package/dist/lib/kmsModule.d.ts.map +1 -0
  107. package/dist/lib/kmsModule.js +327 -0
  108. package/dist/lib/kmsModule.js.map +1 -0
  109. package/dist/lib/kmsRecordCipher.d.ts +82 -0
  110. package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
  111. package/dist/lib/kmsRecordCipher.js +226 -0
  112. package/dist/lib/kmsRecordCipher.js.map +1 -0
  113. package/dist/lib/pagination.d.ts +19 -0
  114. package/dist/lib/pagination.d.ts.map +1 -0
  115. package/dist/lib/pagination.js +21 -0
  116. package/dist/lib/pagination.js.map +1 -0
  117. package/dist/lib/paths.d.ts +273 -0
  118. package/dist/lib/paths.d.ts.map +1 -0
  119. package/dist/lib/paths.js +270 -0
  120. package/dist/lib/paths.js.map +1 -0
  121. package/dist/lib/preconditions.d.ts +65 -0
  122. package/dist/lib/preconditions.d.ts.map +1 -0
  123. package/dist/lib/preconditions.js +112 -0
  124. package/dist/lib/preconditions.js.map +1 -0
  125. package/dist/lib/resourceFileName.d.ts +41 -0
  126. package/dist/lib/resourceFileName.d.ts.map +1 -0
  127. package/dist/lib/resourceFileName.js +58 -0
  128. package/dist/lib/resourceFileName.js.map +1 -0
  129. package/dist/lib/revocations.d.ts +49 -0
  130. package/dist/lib/revocations.d.ts.map +1 -0
  131. package/dist/lib/revocations.js +86 -0
  132. package/dist/lib/revocations.js.map +1 -0
  133. package/dist/lib/validateDid.d.ts +19 -0
  134. package/dist/lib/validateDid.d.ts.map +1 -0
  135. package/dist/lib/validateDid.js +35 -0
  136. package/dist/lib/validateDid.js.map +1 -0
  137. package/dist/lib/validateId.d.ts +66 -0
  138. package/dist/lib/validateId.d.ts.map +1 -0
  139. package/dist/lib/validateId.js +126 -0
  140. package/dist/lib/validateId.js.map +1 -0
  141. package/dist/plugin.d.ts +115 -0
  142. package/dist/plugin.d.ts.map +1 -0
  143. package/dist/plugin.js +134 -0
  144. package/dist/plugin.js.map +1 -0
  145. package/dist/policy.d.ts +78 -0
  146. package/dist/policy.d.ts.map +1 -0
  147. package/dist/policy.js +115 -0
  148. package/dist/policy.js.map +1 -0
  149. package/dist/provisioning.d.ts +38 -0
  150. package/dist/provisioning.d.ts.map +1 -0
  151. package/dist/provisioning.js +72 -0
  152. package/dist/provisioning.js.map +1 -0
  153. package/dist/requests/BackendRequest.d.ts +61 -0
  154. package/dist/requests/BackendRequest.d.ts.map +1 -0
  155. package/dist/requests/BackendRequest.js +152 -0
  156. package/dist/requests/BackendRequest.js.map +1 -0
  157. package/dist/requests/CollectionRequest.d.ts +234 -0
  158. package/dist/requests/CollectionRequest.d.ts.map +1 -0
  159. package/dist/requests/CollectionRequest.js +705 -0
  160. package/dist/requests/CollectionRequest.js.map +1 -0
  161. package/dist/requests/KeyRequest.d.ts +115 -0
  162. package/dist/requests/KeyRequest.d.ts.map +1 -0
  163. package/dist/requests/KeyRequest.js +468 -0
  164. package/dist/requests/KeyRequest.js.map +1 -0
  165. package/dist/requests/KeystoreRequest.d.ts +99 -0
  166. package/dist/requests/KeystoreRequest.d.ts.map +1 -0
  167. package/dist/requests/KeystoreRequest.js +247 -0
  168. package/dist/requests/KeystoreRequest.js.map +1 -0
  169. package/dist/requests/PolicyRequest.d.ts +53 -0
  170. package/dist/requests/PolicyRequest.d.ts.map +1 -0
  171. package/dist/requests/PolicyRequest.js +124 -0
  172. package/dist/requests/PolicyRequest.js.map +1 -0
  173. package/dist/requests/ResourceRequest.d.ts +134 -0
  174. package/dist/requests/ResourceRequest.d.ts.map +1 -0
  175. package/dist/requests/ResourceRequest.js +548 -0
  176. package/dist/requests/ResourceRequest.js.map +1 -0
  177. package/dist/requests/RevocationRequest.d.ts +67 -0
  178. package/dist/requests/RevocationRequest.d.ts.map +1 -0
  179. package/dist/requests/RevocationRequest.js +187 -0
  180. package/dist/requests/RevocationRequest.js.map +1 -0
  181. package/dist/requests/SpaceRequest.d.ts +204 -0
  182. package/dist/requests/SpaceRequest.d.ts.map +1 -0
  183. package/dist/requests/SpaceRequest.js +520 -0
  184. package/dist/requests/SpaceRequest.js.map +1 -0
  185. package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
  186. package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
  187. package/dist/requests/SpacesRepositoryRequest.js +159 -0
  188. package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
  189. package/dist/requests/collectionContext.d.ts +21 -0
  190. package/dist/requests/collectionContext.d.ts.map +1 -0
  191. package/dist/requests/collectionContext.js +30 -0
  192. package/dist/requests/collectionContext.js.map +1 -0
  193. package/dist/requests/controllerConsent.d.ts +58 -0
  194. package/dist/requests/controllerConsent.d.ts.map +1 -0
  195. package/dist/requests/controllerConsent.js +73 -0
  196. package/dist/requests/controllerConsent.js.map +1 -0
  197. package/dist/requests/keystoreContext.d.ts +68 -0
  198. package/dist/requests/keystoreContext.d.ts.map +1 -0
  199. package/dist/requests/keystoreContext.js +87 -0
  200. package/dist/requests/keystoreContext.js.map +1 -0
  201. package/dist/requests/resourceInput.d.ts +19 -0
  202. package/dist/requests/resourceInput.d.ts.map +1 -0
  203. package/dist/requests/resourceInput.js +102 -0
  204. package/dist/requests/resourceInput.js.map +1 -0
  205. package/dist/requests/spaceContext.d.ts +127 -0
  206. package/dist/requests/spaceContext.d.ts.map +1 -0
  207. package/dist/requests/spaceContext.js +201 -0
  208. package/dist/requests/spaceContext.js.map +1 -0
  209. package/dist/routes.d.ts +55 -0
  210. package/dist/routes.d.ts.map +1 -0
  211. package/dist/routes.js +306 -0
  212. package/dist/routes.js.map +1 -0
  213. package/dist/server.d.ts +20 -0
  214. package/dist/server.d.ts.map +1 -0
  215. package/dist/server.js +60 -0
  216. package/dist/server.js.map +1 -0
  217. package/dist/start.d.ts +7 -0
  218. package/dist/start.d.ts.map +1 -0
  219. package/dist/start.js +61 -0
  220. package/dist/start.js.map +1 -0
  221. package/dist/storage.d.ts +29 -0
  222. package/dist/storage.d.ts.map +1 -0
  223. package/dist/storage.js +47 -0
  224. package/dist/storage.js.map +1 -0
  225. package/dist/types.d.ts +915 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/dist/views/home.hbs +9 -0
  230. package/dist/views/templates/main.hbs +12 -0
  231. package/dist/zcap.d.ts +267 -0
  232. package/dist/zcap.d.ts.map +1 -0
  233. package/dist/zcap.js +478 -0
  234. package/dist/zcap.js.map +1 -0
  235. package/package.json +109 -0
  236. package/src/auth-header-hooks.ts +163 -0
  237. package/src/authorize.ts +117 -0
  238. package/src/backends/filesystem.ts +3626 -0
  239. package/src/backends/postgres.ts +3073 -0
  240. package/src/backends/postgresSchema.ts +231 -0
  241. package/src/config.default.ts +657 -0
  242. package/src/corsProxy.ts +326 -0
  243. package/src/declarations.d.ts +28 -0
  244. package/src/digest.ts +223 -0
  245. package/src/errors.ts +1122 -0
  246. package/src/index.ts +21 -0
  247. package/src/lib/atomicFile.ts +176 -0
  248. package/src/lib/backendRegistry.ts +139 -0
  249. package/src/lib/backendUsage.ts +79 -0
  250. package/src/lib/backends.ts +351 -0
  251. package/src/lib/blindedIndex.ts +466 -0
  252. package/src/lib/collectionDescription.ts +43 -0
  253. package/src/lib/cursor.ts +52 -0
  254. package/src/lib/edvEnvelope.ts +120 -0
  255. package/src/lib/encryption.ts +472 -0
  256. package/src/lib/etag.ts +40 -0
  257. package/src/lib/exportManifest.ts +115 -0
  258. package/src/lib/importTar.ts +381 -0
  259. package/src/lib/isJson.ts +18 -0
  260. package/src/lib/keyEpoch.ts +89 -0
  261. package/src/lib/keyedMutex.ts +45 -0
  262. package/src/lib/kmsModule.ts +451 -0
  263. package/src/lib/kmsRecordCipher.ts +292 -0
  264. package/src/lib/pagination.ts +22 -0
  265. package/src/lib/paths.ts +382 -0
  266. package/src/lib/preconditions.ts +145 -0
  267. package/src/lib/resourceFileName.ts +69 -0
  268. package/src/lib/revocations.ts +116 -0
  269. package/src/lib/validateDid.ts +41 -0
  270. package/src/lib/validateId.ts +148 -0
  271. package/src/plugin.ts +288 -0
  272. package/src/policy.ts +176 -0
  273. package/src/provisioning.ts +98 -0
  274. package/src/requests/BackendRequest.ts +219 -0
  275. package/src/requests/CollectionRequest.ts +899 -0
  276. package/src/requests/KeyRequest.ts +617 -0
  277. package/src/requests/KeystoreRequest.ts +325 -0
  278. package/src/requests/PolicyRequest.ts +169 -0
  279. package/src/requests/ResourceRequest.ts +649 -0
  280. package/src/requests/RevocationRequest.ts +267 -0
  281. package/src/requests/SpaceRequest.ts +686 -0
  282. package/src/requests/SpacesRepositoryRequest.ts +194 -0
  283. package/src/requests/collectionContext.ts +41 -0
  284. package/src/requests/controllerConsent.ts +108 -0
  285. package/src/requests/keystoreContext.ts +124 -0
  286. package/src/requests/resourceInput.ts +118 -0
  287. package/src/requests/spaceContext.ts +311 -0
  288. package/src/routes.ts +420 -0
  289. package/src/server.ts +69 -0
  290. package/src/start.ts +71 -0
  291. package/src/storage.ts +60 -0
  292. package/src/types.ts +1016 -0
  293. package/src/views/home.hbs +9 -0
  294. package/src/views/templates/main.hbs +12 -0
  295. package/src/zcap.ts +657 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * At-rest cipher for WebKMS key records (`KMS_RECORD_KEK`, the optional
3
+ * hardening increment). Envelope-encrypts the
4
+ * secret-bearing fields of a stored key so a disk/database dump exposes only
5
+ * ciphertext and a `kekId`, while the wire projection (`KmsKeyDescription`) and
6
+ * the whole authorization model stay byte-for-byte identical.
7
+ *
8
+ * The design mirrors bedrock's `@bedrock/record-cipher`: a deny-by-default field
9
+ * split (an explicit plaintext allowlist; every other `key` field is a secret),
10
+ * a fresh per-record content-encryption key (`A256GCM`) wrapped (`A256KW`) under
11
+ * a config-supplied AES-256 KEK, and a per-record `kekId` so a rotated-in KEK
12
+ * never forces a rewrite. It reuses the same `node:crypto` primitives the KMS
13
+ * module already relies on for AES-KW (`id-aes256-wrap`), so no new crypto
14
+ * dependency is introduced.
15
+ *
16
+ * This module is pure and backend-agnostic: KEK(s) are injected (no env reads
17
+ * here), and it neither touches storage nor knows about Fastify. The
18
+ * orchestration seam (`src/requests/KeyRequest.ts`) applies it around the
19
+ * backend's `insertKey` / `getKey`.
20
+ */
21
+ import {
22
+ createCipheriv,
23
+ createDecipheriv,
24
+ createHash,
25
+ randomBytes
26
+ } from 'node:crypto'
27
+ import { IdDecoder } from '@digitalcredentials/bnid'
28
+ import type {
29
+ KmsEncryptedEnvelope,
30
+ KmsKeyRecord,
31
+ KmsStoredKey,
32
+ RecordKek,
33
+ KmsRecordKekRegistry
34
+ } from '../types.js'
35
+
36
+ /**
37
+ * The plaintext allowlist: the `KmsStoredKey` fields that stay in the clear (and
38
+ * queryable) at rest. EVERY other field of `key` -- `privateKeyMultibase`,
39
+ * `secret`, and any secret-bearing field added later -- is swept into the
40
+ * envelope, so new secrets are protected by default unless explicitly added
41
+ * here. (Bedrock additionally lists `controller`; this server never stores
42
+ * `controller` on the key -- it is read from the live keystore config at
43
+ * description time -- so it is simply absent.)
44
+ */
45
+ export const PLAINTEXT_KEY_FIELDS: ReadonlySet<string> = new Set([
46
+ '@context',
47
+ 'id',
48
+ 'type',
49
+ 'publicKeyMultibase',
50
+ 'maxCapabilityChainLength',
51
+ 'publicAlias',
52
+ 'publicAliasTemplate'
53
+ ])
54
+
55
+ /**
56
+ * The Multikey header for an AES-256 symmetric key (`0xa2 0x01`, the multicodec
57
+ * varint for `aes-256`), the two bytes a `KMS_RECORD_KEK` multibase value
58
+ * carries before its 32 key bytes -- matching bedrock's KEK encoding.
59
+ */
60
+ const AES_256_MULTIKEY_HEADER = Buffer.from([0xa2, 0x01])
61
+
62
+ /** Content-encryption algorithm: AES-256-GCM (`A256GCM`). */
63
+ const CEK_ALGORITHM = 'aes-256-gcm'
64
+ /** GCM initialization-vector length in bytes (96-bit, per the JWE `A256GCM` profile). */
65
+ const GCM_IV_BYTES = 12
66
+ /**
67
+ * Key-wrap algorithm: AES Key Wrap (RFC 3394) with a 256-bit KEK (`A256KW`),
68
+ * the same primitive the KMS module uses for its AES-KW key type. Its default
69
+ * IV is the RFC's `0xA6` octet repeated eight times; the wrap is integrity-
70
+ * checked, so a wrong KEK throws on unwrap.
71
+ */
72
+ const KEK_WRAP_ALGORITHM = 'id-aes256-wrap'
73
+ const KEK_WRAP_DEFAULT_IV = Buffer.alloc(8, 0xa6)
74
+
75
+ /**
76
+ * Derives a KEK's non-secret, stable id from its raw key bytes: a SHA-256 digest
77
+ * (one-way, so storing it per record leaks nothing about the key). Self-
78
+ * describing and deterministic, so a rotated-in KEK gets a stable id from its
79
+ * material alone -- the convention bedrock follows.
80
+ * @param key {Buffer} the 32-byte AES-256 KEK
81
+ * @returns {string} the derived `urn:kek:sha256:<hex>` id
82
+ */
83
+ export function deriveKekId(key: Buffer): string {
84
+ return `urn:kek:sha256:${createHash('sha256').update(key).digest('hex')}`
85
+ }
86
+
87
+ /**
88
+ * Parses a `secretKeyMultibase` (base58btc Multikey) AES-256 KEK into a
89
+ * `RecordKek` (its raw key plus a derived id). Throws a plain `Error` on a
90
+ * malformed value -- callers are config parsing (fails startup) and tests, not
91
+ * the request path. The error messages name `label` (the offending config
92
+ * variable, e.g. a `KMS_RECORD_KEKS entry N` position) but NEVER echo the secret
93
+ * value itself.
94
+ * @param secretKeyMultibase {string} the multibase (`z...`) KEK value
95
+ * @param [label] {string} the config source named in error messages
96
+ * (defaults to `KMS_RECORD_KEK`)
97
+ * @returns {RecordKek}
98
+ */
99
+ export function parseKekMultibase(
100
+ secretKeyMultibase: string,
101
+ label = 'KMS_RECORD_KEK'
102
+ ): RecordKek {
103
+ const decoder = new IdDecoder({ encoding: 'base58', multibase: true })
104
+ let decoded: Buffer
105
+ try {
106
+ decoded = Buffer.from(decoder.decode(secretKeyMultibase))
107
+ } catch {
108
+ throw new Error(`${label} is not a valid multibase (base58btc) value.`)
109
+ }
110
+ if (!decoded.subarray(0, 2).equals(AES_256_MULTIKEY_HEADER)) {
111
+ throw new Error(
112
+ `${label} must carry the AES-256 Multikey header (0xa2 0x01).`
113
+ )
114
+ }
115
+ const key = decoded.subarray(2)
116
+ if (key.length !== 32) {
117
+ throw new Error(
118
+ `${label} must be a 256-bit (32-byte) key; got ${key.length} bytes.`
119
+ )
120
+ }
121
+ return { id: deriveKekId(key), key }
122
+ }
123
+
124
+ /**
125
+ * The KEK that wraps NEW records, or `undefined` when encryption is disabled
126
+ * (no registry, or `currentKekId: null`).
127
+ * @param [registry] {KmsRecordKekRegistry}
128
+ * @returns {RecordKek | undefined}
129
+ */
130
+ export function currentRecordKek(
131
+ registry?: KmsRecordKekRegistry
132
+ ): RecordKek | undefined {
133
+ if (registry === undefined || registry.currentKekId === null) {
134
+ return undefined
135
+ }
136
+ return registry.keks.get(registry.currentKekId)
137
+ }
138
+
139
+ /**
140
+ * A KEK-lookup function over a registry, resolving a record's stored `kekId` to
141
+ * its `RecordKek` for decryption (`undefined` when the KEK is not registered, or
142
+ * there is no registry at all). The decrypt seam for the pass-through /
143
+ * rotation upgrade paths.
144
+ * @param [registry] {KmsRecordKekRegistry}
145
+ * @returns {(kekId: string) => RecordKek | undefined}
146
+ */
147
+ export function recordKekLoader(
148
+ registry?: KmsRecordKekRegistry
149
+ ): (kekId: string) => RecordKek | undefined {
150
+ return (kekId: string) => registry?.keks.get(kekId)
151
+ }
152
+
153
+ /**
154
+ * Encrypts the secret-bearing fields of a key record under a KEK, returning a
155
+ * record whose `key` carries the allowlisted plaintext fields plus an
156
+ * `encrypted` envelope in place of the secrets. Pure: the KEK is injected, no
157
+ * storage or env access. The record's non-`key` members (`keystoreId`,
158
+ * `localId`, `meta`) are unchanged.
159
+ * @param options {object}
160
+ * @param options.record {KmsKeyRecord} the plaintext (secret-bearing) record
161
+ * @param options.kek {RecordKek} the KEK to wrap the fresh CEK under
162
+ * @returns {KmsKeyRecord} the at-rest record (secrets replaced by `encrypted`)
163
+ */
164
+ export function encryptKeyRecord({
165
+ record,
166
+ kek
167
+ }: {
168
+ record: KmsKeyRecord
169
+ kek: RecordKek
170
+ }): KmsKeyRecord {
171
+ const publicFields: Record<string, unknown> = {}
172
+ const secretFields: Record<string, unknown> = {}
173
+ for (const [field, value] of Object.entries(record.key)) {
174
+ if (PLAINTEXT_KEY_FIELDS.has(field)) {
175
+ publicFields[field] = value
176
+ } else {
177
+ secretFields[field] = value
178
+ }
179
+ }
180
+
181
+ const plaintext = Buffer.from(JSON.stringify(secretFields), 'utf8')
182
+
183
+ // Fresh per-record content-encryption key (CEK), A256GCM. The `protected`
184
+ // header doubles as the GCM additional-authenticated-data (AAD), per JWE.
185
+ const cek = randomBytes(32)
186
+ const iv = randomBytes(GCM_IV_BYTES)
187
+ const protectedHeader = Buffer.from(
188
+ JSON.stringify({ enc: 'A256GCM' }),
189
+ 'utf8'
190
+ ).toString('base64url')
191
+ const cipher = createCipheriv(CEK_ALGORITHM, cek, iv)
192
+ cipher.setAAD(Buffer.from(protectedHeader, 'ascii'))
193
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
194
+ const tag = cipher.getAuthTag()
195
+
196
+ // Wrap the CEK under the KEK (A256KW / RFC 3394).
197
+ const wrap = createCipheriv(KEK_WRAP_ALGORITHM, kek.key, KEK_WRAP_DEFAULT_IV)
198
+ const wrappedCek = Buffer.concat([wrap.update(cek), wrap.final()])
199
+
200
+ const encrypted: KmsEncryptedEnvelope = {
201
+ kekId: kek.id,
202
+ jwe: {
203
+ protected: protectedHeader,
204
+ recipients: [
205
+ {
206
+ header: { alg: 'A256KW', kid: kek.id },
207
+ encrypted_key: wrappedCek.toString('base64url')
208
+ }
209
+ ],
210
+ iv: iv.toString('base64url'),
211
+ ciphertext: ciphertext.toString('base64url'),
212
+ tag: tag.toString('base64url')
213
+ },
214
+ encoding: 'json'
215
+ }
216
+
217
+ return {
218
+ ...record,
219
+ key: { ...publicFields, encrypted } as unknown as KmsStoredKey
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Decrypts a key record: pass-through when `key.encrypted` is absent (a
225
+ * plaintext record -- the default, and the pass-through upgrade path), else
226
+ * looks up the KEK by the record's stored `kekId`, unwraps the CEK, decrypts the
227
+ * secret subset, and splices the secret fields back onto the allowlisted ones
228
+ * (dropping the envelope). Pure: KEK(s) injected via `kekLoader`, no storage or
229
+ * env access. Throws a plain `Error` when the record's KEK is not registered or
230
+ * the ciphertext fails to authenticate; the request seam wraps that as a 500.
231
+ * @param options {object}
232
+ * @param options.record {KmsKeyRecord} the at-rest (or plaintext) record
233
+ * @param options.kekLoader {(kekId: string) => RecordKek | undefined} KEK lookup
234
+ * @returns {KmsKeyRecord} the decrypted record (secrets restored on `key`)
235
+ */
236
+ export function decryptKeyRecord({
237
+ record,
238
+ kekLoader
239
+ }: {
240
+ record: KmsKeyRecord
241
+ kekLoader: (kekId: string) => RecordKek | undefined
242
+ }): KmsKeyRecord {
243
+ const { encrypted } = record.key
244
+ if (encrypted === undefined) {
245
+ // Plaintext record: unconditional pass-through (so enabling a KEK on an
246
+ // existing plaintext tree leaves old records readable).
247
+ return record
248
+ }
249
+
250
+ const kek = kekLoader(encrypted.kekId)
251
+ if (kek === undefined) {
252
+ throw new Error(
253
+ `No KEK registered for kekId "${encrypted.kekId}"; ` +
254
+ 'cannot decrypt WebKMS key record.'
255
+ )
256
+ }
257
+ const { jwe } = encrypted
258
+
259
+ // Unwrap the CEK (A256KW), then decrypt the secret subset (A256GCM). Both
260
+ // steps authenticate: a wrong KEK fails the RFC 3394 integrity check, and a
261
+ // tampered ciphertext fails the GCM tag.
262
+ const wrappedCek = Buffer.from(jwe.recipients[0]!.encrypted_key, 'base64url')
263
+ const unwrap = createDecipheriv(
264
+ KEK_WRAP_ALGORITHM,
265
+ kek.key,
266
+ KEK_WRAP_DEFAULT_IV
267
+ )
268
+ const cek = Buffer.concat([unwrap.update(wrappedCek), unwrap.final()])
269
+
270
+ const decipher = createDecipheriv(
271
+ CEK_ALGORITHM,
272
+ cek,
273
+ Buffer.from(jwe.iv, 'base64url')
274
+ )
275
+ decipher.setAAD(Buffer.from(jwe.protected, 'ascii'))
276
+ decipher.setAuthTag(Buffer.from(jwe.tag, 'base64url'))
277
+ const plaintext = Buffer.concat([
278
+ decipher.update(Buffer.from(jwe.ciphertext, 'base64url')),
279
+ decipher.final()
280
+ ])
281
+ const secretFields = JSON.parse(plaintext.toString('utf8')) as Record<
282
+ string,
283
+ unknown
284
+ >
285
+
286
+ // Splice the secrets back onto the public fields, dropping the envelope.
287
+ const { encrypted: _envelope, ...publicFields } = record.key
288
+ return {
289
+ ...record,
290
+ key: { ...publicFields, ...secretFields } as KmsStoredKey
291
+ }
292
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared page sizing for the List Collection operation's cursor-based
3
+ * pagination and the change feed (spec "Pagination"). Both storage backends
4
+ * clamp through these so an oversized `limit` behaves identically everywhere.
5
+ */
6
+
7
+ /**
8
+ * Page sizing: `DEFAULT_PAGE_SIZE` applies when a request omits `limit`;
9
+ * `MAX_PAGE_SIZE` is the server maximum an oversized `limit` is clamped down to
10
+ * (rather than rejected).
11
+ */
12
+ export const DEFAULT_PAGE_SIZE = 100
13
+ export const MAX_PAGE_SIZE = 1000
14
+
15
+ /**
16
+ * Clamps a requested `limit` to `[1, MAX_PAGE_SIZE]`.
17
+ * @param limit {number}
18
+ * @returns {number}
19
+ */
20
+ export function clampPageSize(limit: number): number {
21
+ return Math.min(Math.max(Math.trunc(limit), 1), MAX_PAGE_SIZE)
22
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Single source of truth for the server's relative URL path templates. The route
3
+ * shapes registered in `routes.ts` (the `/spaces` repository, `/space/:spaceId`,
4
+ * `/space/:spaceId/:collectionId`, the `policy` / `linkset` auxiliary resources,
5
+ * and the `export` / `import` actions) are mirrored here as builder functions, so
6
+ * handlers, the policy code, and the storage backends construct the same paths
7
+ * from one place rather than re-deriving them inline (which risks drift from the
8
+ * routes).
9
+ *
10
+ * Builders return the canonical, no-trailing-slash member form by default. The
11
+ * spec assigns distinct meaning to a trailing slash -- it addresses the
12
+ * container / "list-or-add-to" view of a member -- so the container builders take
13
+ * an explicit `trailingSlash` option (or, for the always-container `/spaces/` and
14
+ * `.../collections/`, bake the slash in) rather than leaving the slash to drift
15
+ * across call sites. A leaf Resource has no children, so `resourcePath` has no
16
+ * container form.
17
+ */
18
+
19
+ /**
20
+ * The SpacesRepository container (`/spaces/`) or one of its members
21
+ * (`/spaces/:spaceId`). The member form is used for the `Location` header of a
22
+ * newly created Space; the container form is the `POST`/`GET` target.
23
+ * @param options {object}
24
+ * @param [options.spaceId] {string} when present, the repository member path;
25
+ * otherwise the (trailing-slash) container path
26
+ * @returns {string}
27
+ */
28
+ export function spacesPath({ spaceId }: { spaceId?: string } = {}): string {
29
+ return spaceId !== undefined ? `/spaces/${spaceId}` : `/spaces/`
30
+ }
31
+
32
+ /**
33
+ * `/space/:spaceId` (member) or `/space/:spaceId/` (container -- the
34
+ * "add a Collection" / Space-as-container view) when `trailingSlash` is set.
35
+ * @param options {object}
36
+ * @param options.spaceId {string}
37
+ * @param [options.trailingSlash] {boolean} address the container form
38
+ * @returns {string}
39
+ */
40
+ export function spacePath({
41
+ spaceId,
42
+ trailingSlash = false
43
+ }: {
44
+ spaceId: string
45
+ trailingSlash?: boolean
46
+ }): string {
47
+ return `/space/${spaceId}${trailingSlash ? '/' : ''}`
48
+ }
49
+
50
+ /**
51
+ * `/space/:spaceId/collections/` -- the "List Collections" container path.
52
+ * @param options {object}
53
+ * @param options.spaceId {string}
54
+ * @returns {string}
55
+ */
56
+ export function collectionsPath({ spaceId }: { spaceId: string }): string {
57
+ return `${spacePath({ spaceId })}/collections/`
58
+ }
59
+
60
+ /**
61
+ * `/space/:spaceId/export` -- the "Export Space" action path.
62
+ * @param options {object}
63
+ * @param options.spaceId {string}
64
+ * @returns {string}
65
+ */
66
+ export function exportPath({ spaceId }: { spaceId: string }): string {
67
+ return `${spacePath({ spaceId })}/export`
68
+ }
69
+
70
+ /**
71
+ * `/space/:spaceId/import` -- the "Import Space" action path.
72
+ * @param options {object}
73
+ * @param options.spaceId {string}
74
+ * @returns {string}
75
+ */
76
+ export function importPath({ spaceId }: { spaceId: string }): string {
77
+ return `${spacePath({ spaceId })}/import`
78
+ }
79
+
80
+ /**
81
+ * `/space/:spaceId/:collectionId` (member) or `/space/:spaceId/:collectionId/`
82
+ * (container -- the "add a Resource" / "list items" view) when `trailingSlash`
83
+ * is set.
84
+ * @param options {object}
85
+ * @param options.spaceId {string}
86
+ * @param options.collectionId {string}
87
+ * @param [options.trailingSlash] {boolean} address the container form
88
+ * @returns {string}
89
+ */
90
+ export function collectionPath({
91
+ spaceId,
92
+ collectionId,
93
+ trailingSlash = false
94
+ }: {
95
+ spaceId: string
96
+ collectionId: string
97
+ trailingSlash?: boolean
98
+ }): string {
99
+ return `/space/${spaceId}/${collectionId}${trailingSlash ? '/' : ''}`
100
+ }
101
+
102
+ /**
103
+ * `/space/:spaceId/:collectionId/:resourceId`
104
+ * @param options {object}
105
+ * @param options.spaceId {string}
106
+ * @param options.collectionId {string}
107
+ * @param options.resourceId {string}
108
+ * @returns {string}
109
+ */
110
+ export function resourcePath({
111
+ spaceId,
112
+ collectionId,
113
+ resourceId
114
+ }: {
115
+ spaceId: string
116
+ collectionId: string
117
+ resourceId: string
118
+ }): string {
119
+ return `/space/${spaceId}/${collectionId}/${resourceId}`
120
+ }
121
+
122
+ /**
123
+ * The `policy` auxiliary resource path for whichever level the ids address:
124
+ * Space (`spaceId`), Collection (`+ collectionId`), or Resource
125
+ * (`+ collectionId + resourceId`).
126
+ * @param options {object}
127
+ * @param options.spaceId {string}
128
+ * @param [options.collectionId] {string}
129
+ * @param [options.resourceId] {string}
130
+ * @returns {string}
131
+ */
132
+ export function policyPath({
133
+ spaceId,
134
+ collectionId,
135
+ resourceId
136
+ }: {
137
+ spaceId: string
138
+ collectionId?: string
139
+ resourceId?: string
140
+ }): string {
141
+ if (collectionId !== undefined && resourceId !== undefined) {
142
+ return `${resourcePath({ spaceId, collectionId, resourceId })}/policy`
143
+ }
144
+ if (collectionId !== undefined) {
145
+ return `${collectionPath({ spaceId, collectionId })}/policy`
146
+ }
147
+ return `${spacePath({ spaceId })}/policy`
148
+ }
149
+
150
+ /**
151
+ * `/space/:spaceId/backends` -- the "Space Backends Available" list path.
152
+ * @param options {object}
153
+ * @param options.spaceId {string}
154
+ * @returns {string}
155
+ */
156
+ export function backendsPath({ spaceId }: { spaceId: string }): string {
157
+ return `${spacePath({ spaceId })}/backends`
158
+ }
159
+
160
+ /**
161
+ * `/space/:spaceId/backends/:backendId` -- a single registered `external`
162
+ * backend record (the `POST` `Location` target, and the `PUT`/`DELETE` member
163
+ * path). Distinct from {@link backendPath}, which is a Collection's *selected*
164
+ * backend resource.
165
+ * @param options {object}
166
+ * @param options.spaceId {string}
167
+ * @param options.backendId {string}
168
+ * @returns {string}
169
+ */
170
+ export function registeredBackendPath({
171
+ spaceId,
172
+ backendId
173
+ }: {
174
+ spaceId: string
175
+ backendId: string
176
+ }): string {
177
+ return `${backendsPath({ spaceId })}/${backendId}`
178
+ }
179
+
180
+ /**
181
+ * `/space/:spaceId/quotas` -- the Space Quota report path (spec "Quotas").
182
+ * @param options {object}
183
+ * @param options.spaceId {string}
184
+ * @returns {string}
185
+ */
186
+ export function quotasPath({ spaceId }: { spaceId: string }): string {
187
+ return `${spacePath({ spaceId })}/quotas`
188
+ }
189
+
190
+ /**
191
+ * `/space/:spaceId/:collectionId/:resourceId/meta` -- the Resource Metadata
192
+ * (reserved `meta` segment) path. Reserved only at the Resource level, so it
193
+ * takes the full id triple.
194
+ * @param options {object}
195
+ * @param options.spaceId {string}
196
+ * @param options.collectionId {string}
197
+ * @param options.resourceId {string}
198
+ * @returns {string}
199
+ */
200
+ export function metaPath({
201
+ spaceId,
202
+ collectionId,
203
+ resourceId
204
+ }: {
205
+ spaceId: string
206
+ collectionId: string
207
+ resourceId: string
208
+ }): string {
209
+ return `${resourcePath({ spaceId, collectionId, resourceId })}/meta`
210
+ }
211
+
212
+ /**
213
+ * `/space/:spaceId/:collectionId/backend` -- the "Collection Backend Selected"
214
+ * resource path (reserved `backend` segment at the Resource level).
215
+ * @param options {object}
216
+ * @param options.spaceId {string}
217
+ * @param options.collectionId {string}
218
+ * @returns {string}
219
+ */
220
+ export function backendPath({
221
+ spaceId,
222
+ collectionId
223
+ }: {
224
+ spaceId: string
225
+ collectionId: string
226
+ }): string {
227
+ return `${collectionPath({ spaceId, collectionId })}/backend`
228
+ }
229
+
230
+ /**
231
+ * `/space/:spaceId/:collectionId/quota` -- the per-Collection storage quota
232
+ * report path (reserved `quota` segment at the Resource level; spec "Quotas").
233
+ * @param options {object}
234
+ * @param options.spaceId {string}
235
+ * @param options.collectionId {string}
236
+ * @returns {string}
237
+ */
238
+ export function quotaPath({
239
+ spaceId,
240
+ collectionId
241
+ }: {
242
+ spaceId: string
243
+ collectionId: string
244
+ }): string {
245
+ return `${collectionPath({ spaceId, collectionId })}/quota`
246
+ }
247
+
248
+ /**
249
+ * `/space/:spaceId/:collectionId/query` -- the reserved `query` endpoint for a
250
+ * Collection (spec "Collection-level reserved endpoints"). The WAS server serves
251
+ * the replication change feed as the `changes` profile of this endpoint.
252
+ * @param options {object}
253
+ * @param options.spaceId {string}
254
+ * @param options.collectionId {string}
255
+ * @returns {string}
256
+ */
257
+ export function queryPath({
258
+ spaceId,
259
+ collectionId
260
+ }: {
261
+ spaceId: string
262
+ collectionId: string
263
+ }): string {
264
+ return `${collectionPath({ spaceId, collectionId })}/query`
265
+ }
266
+
267
+ /**
268
+ * The `linkset` discovery resource path for a Space (`spaceId`) or Collection
269
+ * (`+ collectionId`).
270
+ * @param options {object}
271
+ * @param options.spaceId {string}
272
+ * @param [options.collectionId] {string}
273
+ * @returns {string}
274
+ */
275
+ export function linksetPath({
276
+ spaceId,
277
+ collectionId
278
+ }: {
279
+ spaceId: string
280
+ collectionId?: string
281
+ }): string {
282
+ const anchor =
283
+ collectionId !== undefined
284
+ ? collectionPath({ spaceId, collectionId })
285
+ : spacePath({ spaceId })
286
+ return `${anchor}/linkset`
287
+ }
288
+
289
+ /**
290
+ * `/kms/keystores` -- the WebKMS keystores collection (create/list target; the
291
+ * `/kms` facet) or one of its member keystores
292
+ * (`/kms/keystores/:keystoreId`). Unlike the WAS builders there is no
293
+ * trailing-slash container form: the webkms protocol
294
+ * (`@interop/webkms-client`) posts to the bare collection path.
295
+ * @param options {object}
296
+ * @param [options.keystoreId] {string} the keystore's server-generated local
297
+ * id; when present, the member path, otherwise the collection path
298
+ * @returns {string}
299
+ */
300
+ export function kmsKeystoresPath({
301
+ keystoreId
302
+ }: { keystoreId?: string } = {}): string {
303
+ return keystoreId !== undefined
304
+ ? `/kms/keystores/${keystoreId}`
305
+ : '/kms/keystores'
306
+ }
307
+
308
+ /**
309
+ * `/kms/keystores/:keystoreId/keys` -- a keystore's keys collection (the
310
+ * `GenerateKeyOperation` target) or one of its member keys
311
+ * (`/kms/keystores/:keystoreId/keys/:keyId`, the key-operation / description
312
+ * target). Like `kmsKeystoresPath`, no trailing-slash form exists: the webkms
313
+ * protocol's URLs are exact.
314
+ * @param options {object}
315
+ * @param options.keystoreId {string} the keystore's local id
316
+ * @param [options.keyId] {string} the key's server-generated local id; when
317
+ * present, the member path, otherwise the collection path
318
+ * @returns {string}
319
+ */
320
+ export function kmsKeysPath({
321
+ keystoreId,
322
+ keyId
323
+ }: {
324
+ keystoreId: string
325
+ keyId?: string
326
+ }): string {
327
+ const keysPath = `${kmsKeystoresPath({ keystoreId })}/keys`
328
+ return keyId !== undefined ? `${keysPath}/${keyId}` : keysPath
329
+ }
330
+
331
+ /**
332
+ * `/kms/keystores/:keystoreId/zcaps/revocations/:revocationId` -- a keystore's
333
+ * zcap revocation submission target (POST; the ezcap-express
334
+ * `/zcaps/revocations/` convention). `revocationId` is the *to-be-revoked
335
+ * capability's id*, URL-encoded into the single path segment the route
336
+ * expects -- the same `encodeURIComponent` framing `@interop/webkms-client`'s
337
+ * `revokeCapability` puts on the wire.
338
+ * @param options {object}
339
+ * @param options.keystoreId {string} the keystore's local id
340
+ * @param options.revocationId {string} the to-be-revoked capability's id
341
+ * (raw, un-encoded)
342
+ * @returns {string}
343
+ */
344
+ export function kmsRevocationsPath({
345
+ keystoreId,
346
+ revocationId
347
+ }: {
348
+ keystoreId: string
349
+ revocationId: string
350
+ }): string {
351
+ return (
352
+ `${kmsKeystoresPath({ keystoreId })}/zcaps/revocations/` +
353
+ encodeURIComponent(revocationId)
354
+ )
355
+ }
356
+
357
+ /**
358
+ * `/space/:spaceId/zcaps/revocations/:revocationId` -- a Space's zcap
359
+ * revocation submission target (POST), the WAS-route sibling of
360
+ * `kmsRevocationsPath` and the same ezcap-express `/zcaps/revocations/`
361
+ * convention. `revocationId` is the *to-be-revoked capability's id*,
362
+ * URL-encoded into the single path segment the route expects. The `zcaps`
363
+ * segment sits four levels under `/space`, deeper than any Collection or
364
+ * Resource route, so it shadows neither and needs no reserved-id entry.
365
+ * @param options {object}
366
+ * @param options.spaceId {string}
367
+ * @param options.revocationId {string} the to-be-revoked capability's id
368
+ * (raw, un-encoded)
369
+ * @returns {string}
370
+ */
371
+ export function spaceRevocationsPath({
372
+ spaceId,
373
+ revocationId
374
+ }: {
375
+ spaceId: string
376
+ revocationId: string
377
+ }): string {
378
+ return (
379
+ `${spacePath({ spaceId })}/zcaps/revocations/` +
380
+ encodeURIComponent(revocationId)
381
+ )
382
+ }