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,617 @@
1
+ /**
2
+ * Request handlers for WebKMS key operations (the `/kms` facet):
3
+ * - POST /kms/keystores/:keystoreId/keys (GenerateKeyOperation)
4
+ * - GET /kms/keystores/:keystoreId/keys (List Keys -- a fork extension beyond
5
+ * upstream webkms-switch; enumerate the keystore's public key descriptions)
6
+ * - POST /kms/keystores/:keystoreId/keys/:keyId (Sign / Verify / DeriveSecret /
7
+ * WrapKey / UnwrapKey operation, dispatched by envelope `type`)
8
+ * - GET /kms/keystores/:keystoreId/keys/:keyId (public key description)
9
+ *
10
+ * The wire contract is protocol-fixed by webkms-switch /
11
+ * `@interop/webkms-client` (the conformance suite): operation envelopes carry
12
+ * an optional `@context`, generate responds 200 (not 201) with a `Location`
13
+ * header and a `{ keyId, keyDescription }` body, every other operation
14
+ * responds 200 with its own single-field result. Every route roots its
15
+ * capability in the **keystore** URL (never the key -- the client computes the
16
+ * root zcap by stripping the key URL at the last `/keys/`), with the key URL
17
+ * accepted as an attenuated target; the expected zcap action is the operation
18
+ * name, decapitalized, minus `Operation` (`generateKey`, `sign`, ...), and
19
+ * `read` for the description GET (which this server authorizes rather than
20
+ * leaving open).
21
+ */
22
+ import type { FastifyReply, FastifyRequest } from 'fastify'
23
+ import { generateId } from '@digitalcredentials/bnid'
24
+ import { fetchKeystoreAndVerify } from './keystoreContext.js'
25
+ import {
26
+ generateKmsKey,
27
+ describeKmsKey,
28
+ runKeyOperation
29
+ } from '../lib/kmsModule.js'
30
+ import {
31
+ encryptKeyRecord,
32
+ decryptKeyRecord,
33
+ currentRecordKek,
34
+ recordKekLoader
35
+ } from '../lib/kmsRecordCipher.js'
36
+ import { isUrlSafeSegment } from '../lib/validateId.js'
37
+ import { kmsKeysPath } from '../lib/paths.js'
38
+ import { encodeCursor, decodeCursor } from '../lib/cursor.js'
39
+ import { KEY_LIST_LIMIT } from '../config.default.js'
40
+ import {
41
+ InvalidRequestBodyError,
42
+ KeyNotFoundError,
43
+ KmsRecordCipherError,
44
+ UnauthorizedError,
45
+ UnsupportedKeyOperationError
46
+ } from '../errors.js'
47
+ import type { KmsKeyRecord, KmsRecordKekRegistry } from '../types.js'
48
+
49
+ /**
50
+ * The operation-specific body fields of each dispatchable (non-generate)
51
+ * operation envelope. Doubles as the recognized-operation registry: an
52
+ * envelope `type` outside this table is the clean 400 not-supported. The
53
+ * fields' *values* are validated by the KMS module's per-operation
54
+ * implementations; the request layer enforces the envelope shape
55
+ * (`additionalProperties: false`, per the webkms-switch schemas).
56
+ */
57
+ const OPERATION_FIELDS: Record<string, string[]> = {
58
+ SignOperation: ['verifyData'],
59
+ VerifyOperation: ['verifyData', 'signatureValue'],
60
+ DeriveSecretOperation: ['publicKey'],
61
+ WrapKeyOperation: ['unwrappedKey'],
62
+ UnwrapKeyOperation: ['wrappedKey']
63
+ }
64
+
65
+ /**
66
+ * Derives the expected zcap action from an operation envelope `type`:
67
+ * decapitalized, minus the `Operation` suffix (webkms-switch `_parseAction`) --
68
+ * `SignOperation` to `sign`, `DeriveSecretOperation` to `deriveSecret`, etc.
69
+ * @param operationType {string}
70
+ * @returns {string}
71
+ */
72
+ function operationAction(operationType: string): string {
73
+ const name = operationType.slice(0, operationType.indexOf('Operation'))
74
+ return name.charAt(0).toLowerCase() + name.slice(1)
75
+ }
76
+
77
+ /**
78
+ * Asserts the request body is a JSON object envelope of the given operation
79
+ * `type` carrying only the allowed keys (`additionalProperties: false`).
80
+ * `@context` is allowed but never required -- the webkms-switch schemas make
81
+ * it optional and `@interop/webkms-client` omits it entirely.
82
+ *
83
+ * @param options {object}
84
+ * @param options.body {unknown} the parsed request body
85
+ * @param options.allowedKeys {string[]} allowed keys beyond `@context`/`type`
86
+ * @param options.requestName {string} request name used in error titles
87
+ * @returns {Record<string, unknown>} the body, narrowed
88
+ */
89
+ function assertOperationEnvelope({
90
+ body,
91
+ allowedKeys,
92
+ requestName
93
+ }: {
94
+ body: unknown
95
+ allowedKeys: string[]
96
+ requestName: string
97
+ }): Record<string, unknown> {
98
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
99
+ throw new InvalidRequestBodyError({
100
+ requestName,
101
+ detail: 'Operation body must be a JSON object.'
102
+ })
103
+ }
104
+ const envelope = body as Record<string, unknown>
105
+ for (const key of Object.keys(envelope)) {
106
+ if (key !== '@context' && key !== 'type' && !allowedKeys.includes(key)) {
107
+ throw new InvalidRequestBodyError({
108
+ requestName,
109
+ detail: `Unexpected operation property "${key}".`,
110
+ pointer: `#/${key}`
111
+ })
112
+ }
113
+ }
114
+ return envelope
115
+ }
116
+
117
+ /**
118
+ * Decrypts a stored key record through the at-rest cipher seam
119
+ * (`KMS_RECORD_KEK`): a plaintext record passes through untouched; an encrypted
120
+ * one is unwrapped under its stored `kekId` (a failure -- missing KEK / bad
121
+ * ciphertext -- is a 500 `KmsRecordCipherError`, a server misconfiguration).
122
+ * The single funnel every path that needs the *secret* (operation dispatch,
123
+ * description GET) goes through, so no operation can read a ciphertext envelope
124
+ * as key material. List Keys deliberately does NOT use it: it projects only
125
+ * public fields, which stay plaintext at rest, so it never unwraps a secret.
126
+ *
127
+ * @param options {object}
128
+ * @param options.record {KmsKeyRecord} the record as stored (maybe encrypted)
129
+ * @param [options.kmsRecordKek] {KmsRecordKekRegistry} the configured KEK
130
+ * registry (`request.server.kmsRecordKek`), or `undefined` when disabled
131
+ * @param options.requestName {string} request name used in error titles
132
+ * @returns {KmsKeyRecord} the decrypted (secret-bearing) record
133
+ */
134
+ function decryptStoredKeyRecord({
135
+ record,
136
+ kmsRecordKek,
137
+ requestName
138
+ }: {
139
+ record: KmsKeyRecord
140
+ kmsRecordKek?: KmsRecordKekRegistry
141
+ requestName: string
142
+ }): KmsKeyRecord {
143
+ try {
144
+ return decryptKeyRecord({
145
+ record,
146
+ kekLoader: recordKekLoader(kmsRecordKek)
147
+ })
148
+ } catch (err) {
149
+ throw new KmsRecordCipherError({ cause: err as Error, requestName })
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Loads a key record by its URL params, masking an unknown (or non-URL-safe)
155
+ * key id as the 404 `KeyNotFoundError`. Call after capability verification,
156
+ * so an under-authorized caller cannot distinguish absent from forbidden. The
157
+ * record is decrypted through the cipher seam (see `decryptStoredKeyRecord`)
158
+ * before it is returned.
159
+ *
160
+ * @param options {object}
161
+ * @param options.request {FastifyRequest} supplies `request.server.storage`
162
+ * @param options.keystoreId {string} the keystore's local id (URL param)
163
+ * @param options.keyId {string} the key's local id (URL param)
164
+ * @param options.requestName {string} request name used in error titles
165
+ * @returns {Promise<KmsKeyRecord>} the decrypted (secret-bearing) record
166
+ */
167
+ async function fetchKeyRecord({
168
+ request,
169
+ keystoreId,
170
+ keyId,
171
+ requestName
172
+ }: {
173
+ request: FastifyRequest
174
+ keystoreId: string
175
+ keyId: string
176
+ requestName: string
177
+ }): Promise<KmsKeyRecord> {
178
+ if (!isUrlSafeSegment(keyId)) {
179
+ throw new KeyNotFoundError({ requestName })
180
+ }
181
+ const record = await request.server.storage.getKey({
182
+ keystoreId,
183
+ localId: keyId
184
+ })
185
+ if (!record) {
186
+ throw new KeyNotFoundError({ requestName })
187
+ }
188
+ return decryptStoredKeyRecord({
189
+ record,
190
+ kmsRecordKek: request.server.kmsRecordKek,
191
+ requestName
192
+ })
193
+ }
194
+
195
+ /**
196
+ * Asserts a `GenerateKeyOperation`'s object `invocationTarget`: a required
197
+ * non-empty string `type`, an optional integer `maxCapabilityChainLength`
198
+ * (1-10; the chain includes the root, so 1 means controller-only), and at
199
+ * most one of `publicAlias` / `publicAliasTemplate` (non-empty strings).
200
+ *
201
+ * @param options {object}
202
+ * @param options.invocationTarget {unknown} the envelope's invocationTarget
203
+ * @param options.requestName {string} request name used in error titles
204
+ * @returns {object} the narrowed target fields
205
+ */
206
+ function assertGenerateKeyTarget({
207
+ invocationTarget,
208
+ requestName
209
+ }: {
210
+ invocationTarget: unknown
211
+ requestName: string
212
+ }): {
213
+ type: string
214
+ maxCapabilityChainLength?: number
215
+ publicAlias?: string
216
+ publicAliasTemplate?: string
217
+ } {
218
+ if (
219
+ typeof invocationTarget !== 'object' ||
220
+ invocationTarget === null ||
221
+ Array.isArray(invocationTarget)
222
+ ) {
223
+ throw new InvalidRequestBodyError({
224
+ requestName,
225
+ detail: 'Operation "invocationTarget" must be an object.',
226
+ pointer: '#/invocationTarget'
227
+ })
228
+ }
229
+ const allowedKeys = [
230
+ 'type',
231
+ 'maxCapabilityChainLength',
232
+ 'publicAlias',
233
+ 'publicAliasTemplate'
234
+ ]
235
+ for (const key of Object.keys(invocationTarget)) {
236
+ if (!allowedKeys.includes(key)) {
237
+ throw new InvalidRequestBodyError({
238
+ requestName,
239
+ detail: `Unexpected invocationTarget property "${key}".`,
240
+ pointer: `#/invocationTarget/${key}`
241
+ })
242
+ }
243
+ }
244
+ const target = invocationTarget as {
245
+ type?: unknown
246
+ maxCapabilityChainLength?: unknown
247
+ publicAlias?: unknown
248
+ publicAliasTemplate?: unknown
249
+ }
250
+ if (typeof target.type !== 'string' || target.type.length === 0) {
251
+ throw new InvalidRequestBodyError({
252
+ requestName,
253
+ detail: 'invocationTarget "type" must be a non-empty string.',
254
+ pointer: '#/invocationTarget/type'
255
+ })
256
+ }
257
+ if (
258
+ target.maxCapabilityChainLength !== undefined &&
259
+ (!Number.isInteger(target.maxCapabilityChainLength) ||
260
+ (target.maxCapabilityChainLength as number) < 1 ||
261
+ (target.maxCapabilityChainLength as number) > 10)
262
+ ) {
263
+ throw new InvalidRequestBodyError({
264
+ requestName,
265
+ detail:
266
+ 'invocationTarget "maxCapabilityChainLength" must be an integer' +
267
+ ' between 1 and 10.',
268
+ pointer: '#/invocationTarget/maxCapabilityChainLength'
269
+ })
270
+ }
271
+ for (const aliasField of ['publicAlias', 'publicAliasTemplate'] as const) {
272
+ const value = target[aliasField]
273
+ if (
274
+ value !== undefined &&
275
+ (typeof value !== 'string' || value.length === 0)
276
+ ) {
277
+ throw new InvalidRequestBodyError({
278
+ requestName,
279
+ detail: `invocationTarget "${aliasField}" must be a non-empty string.`,
280
+ pointer: `#/invocationTarget/${aliasField}`
281
+ })
282
+ }
283
+ }
284
+ if (
285
+ target.publicAlias !== undefined &&
286
+ target.publicAliasTemplate !== undefined
287
+ ) {
288
+ throw new InvalidRequestBodyError({
289
+ requestName,
290
+ detail:
291
+ 'Only one of "publicAlias" or "publicAliasTemplate" may be given.',
292
+ pointer: '#/invocationTarget/publicAlias'
293
+ })
294
+ }
295
+ return target as {
296
+ type: string
297
+ maxCapabilityChainLength?: number
298
+ publicAlias?: string
299
+ publicAliasTemplate?: string
300
+ }
301
+ }
302
+
303
+ export class KeyRequest {
304
+ /**
305
+ * POST /kms/keystores/:keystoreId/keys
306
+ * Generate Key (`GenerateKeyOperation`). The envelope's `invocationTarget`
307
+ * is an *object* (`{ type, maxCapabilityChainLength?, publicAlias? |
308
+ * publicAliasTemplate? }`), unlike every other operation's string. Verified
309
+ * against the keystore's controller with the `generateKey` action; the new
310
+ * key's local id is server-generated (the same bnid multihash scheme as
311
+ * keystore ids, per webkms-switch `generateRandom`). Responds 200 with a
312
+ * `Location` header and `{ keyId, keyDescription }` -- the client reads the
313
+ * key id from the *body*, never the header.
314
+ *
315
+ * @param request {import('fastify').FastifyRequest}
316
+ * @param reply {import('fastify').FastifyReply}
317
+ * @returns {Promise<FastifyReply>}
318
+ */
319
+ static async generate(
320
+ request: FastifyRequest<{
321
+ Params: { keystoreId: string }
322
+ Body: Record<string, unknown>
323
+ }>,
324
+ reply: FastifyReply
325
+ ): Promise<FastifyReply> {
326
+ const requestName = 'Generate Key'
327
+ const { keystoreId } = request.params
328
+ const { storage } = request.server
329
+
330
+ const envelope = assertOperationEnvelope({
331
+ body: request.body,
332
+ allowedKeys: ['invocationTarget'],
333
+ requestName
334
+ })
335
+ if (envelope.type !== 'GenerateKeyOperation') {
336
+ throw new InvalidRequestBodyError({
337
+ requestName,
338
+ detail: 'Operation "type" must be "GenerateKeyOperation".',
339
+ pointer: '#/type'
340
+ })
341
+ }
342
+ const target = assertGenerateKeyTarget({
343
+ invocationTarget: envelope.invocationTarget,
344
+ requestName
345
+ })
346
+
347
+ const { config } = await fetchKeystoreAndVerify({
348
+ request,
349
+ keystoreId,
350
+ allowedAction: 'generateKey',
351
+ requestName,
352
+ allowTargetAttenuation: true
353
+ })
354
+
355
+ // Server-generated local id, per webkms-switch `generateRandom`: multibase
356
+ // base58btc of a multihash-framed (identity, 16-byte) 128-bit random value.
357
+ const localId = await generateId({ multihash: true })
358
+ const keyId = `${config.id}/keys/${localId}`
359
+ const { key, keyDescription } = await generateKmsKey({
360
+ keyId,
361
+ type: target.type,
362
+ controller: config.controller,
363
+ maxCapabilityChainLength: target.maxCapabilityChainLength,
364
+ publicAlias: target.publicAlias,
365
+ publicAliasTemplate: target.publicAliasTemplate
366
+ })
367
+ const now = new Date().toISOString()
368
+ // At-rest cipher seam (`KMS_RECORD_KEK`): when a current KEK is configured,
369
+ // encrypt the record's secret fields before it reaches storage (the record
370
+ // stays opaque to the backend); unconfigured, it is stored plaintext.
371
+ let record: KmsKeyRecord = {
372
+ keystoreId,
373
+ localId,
374
+ meta: { created: now, updated: now },
375
+ key
376
+ }
377
+ const kek = currentRecordKek(request.server.kmsRecordKek)
378
+ if (kek !== undefined) {
379
+ record = encryptKeyRecord({ record, kek })
380
+ }
381
+ await storage.insertKey({ keystoreId, localId, record })
382
+
383
+ // 200 (not the keystore create's 201) with a Location header, per
384
+ // webkms-switch `runOperationMiddleware`.
385
+ reply.header('Location', keyId)
386
+ return reply.send({ keyId, keyDescription })
387
+ }
388
+
389
+ /**
390
+ * POST /kms/keystores/:keystoreId/keys/:keyId
391
+ * Key operation dispatch by envelope `type` (Sign / Verify / DeriveSecret /
392
+ * WrapKey / UnwrapKey). The envelope's string `invocationTarget` must equal
393
+ * the request URL exactly (400, per webkms-switch); the expected zcap action
394
+ * is the decapitalized operation name, verified against the keystore's
395
+ * controller with the key URL as an attenuated target. An operation the KMS
396
+ * does not recognize -- or does not serve for the key's type, e.g.
397
+ * `VerifyOperation` on an asymmetric key -- is a clean 400 not-supported.
398
+ * Responds 200 with the operation's single-field result.
399
+ *
400
+ * @param request {import('fastify').FastifyRequest}
401
+ * @param reply {import('fastify').FastifyReply}
402
+ * @returns {Promise<FastifyReply>}
403
+ */
404
+ static async operation(
405
+ request: FastifyRequest<{
406
+ Params: { keystoreId: string; keyId: string }
407
+ Body: Record<string, unknown>
408
+ }>,
409
+ reply: FastifyReply
410
+ ): Promise<FastifyReply> {
411
+ const requestName = 'Key Operation'
412
+ const { keystoreId, keyId } = request.params
413
+ const { serverUrl } = request.server
414
+
415
+ if (
416
+ typeof request.body !== 'object' ||
417
+ request.body === null ||
418
+ typeof (request.body as Record<string, unknown>).type !== 'string'
419
+ ) {
420
+ throw new InvalidRequestBodyError({
421
+ requestName,
422
+ detail: 'Operation body must be a JSON object with a string "type".',
423
+ pointer: '#/type'
424
+ })
425
+ }
426
+ const operationType = (request.body as Record<string, unknown>)
427
+ .type as string
428
+ const operationFields = OPERATION_FIELDS[operationType]
429
+ if (operationFields === undefined) {
430
+ // Unknown-but-well-formed operation types (GenerateKeyOperation included:
431
+ // it is only dispatchable at the keys-collection URL) are not served.
432
+ throw new UnsupportedKeyOperationError({ operationType })
433
+ }
434
+ const envelope = assertOperationEnvelope({
435
+ body: request.body,
436
+ allowedKeys: ['invocationTarget', ...operationFields],
437
+ requestName
438
+ })
439
+ // The envelope names the key it operates on; it must be the key it was
440
+ // posted to (webkms-switch's invocation-target-vs-request-URL 400).
441
+ const fullRequestUrl = new URL(
442
+ kmsKeysPath({ keystoreId, keyId }),
443
+ serverUrl
444
+ ).toString()
445
+ if (envelope.invocationTarget !== fullRequestUrl) {
446
+ throw new InvalidRequestBodyError({
447
+ requestName,
448
+ detail: 'Operation "invocationTarget" does not match the request URL.',
449
+ pointer: '#/invocationTarget'
450
+ })
451
+ }
452
+
453
+ const { dereferencedChainLength } = await fetchKeystoreAndVerify({
454
+ request,
455
+ keystoreId,
456
+ allowedAction: operationAction(operationType),
457
+ requestName,
458
+ allowTargetAttenuation: true
459
+ })
460
+ const record = await fetchKeyRecord({
461
+ request,
462
+ keystoreId,
463
+ keyId,
464
+ requestName
465
+ })
466
+ // Per-key invocation chain bound, set at generate time and enforced at
467
+ // operation time. The chain includes the root, so a bound of 1 means
468
+ // controller-only. An
469
+ // over-long chain is an authorization failure: masked 404, per the
470
+ // server-wide convention.
471
+ if (
472
+ record.key.maxCapabilityChainLength !== undefined &&
473
+ dereferencedChainLength > record.key.maxCapabilityChainLength
474
+ ) {
475
+ throw new UnauthorizedError({ requestName })
476
+ }
477
+
478
+ const result = await runKeyOperation({
479
+ key: record.key,
480
+ operation: envelope
481
+ })
482
+ return reply.send(result)
483
+ }
484
+
485
+ /**
486
+ * GET /kms/keystores/:keystoreId/keys/:keyId
487
+ * Public key description: capability-verified against the keystore's
488
+ * controller (`read`, with the key URL as an attenuated target -- this
489
+ * server authorizes the route rather than serving it open). The
490
+ * description's `controller` is the keystore's *live*
491
+ * controller, and the `publicAlias` / `publicAliasTemplate` override is
492
+ * re-applied on every read, so descriptions are stable (the client caches
493
+ * them for five minutes).
494
+ *
495
+ * @param request {import('fastify').FastifyRequest}
496
+ * @param reply {import('fastify').FastifyReply}
497
+ * @returns {Promise<FastifyReply>}
498
+ */
499
+ static async get(
500
+ request: FastifyRequest<{
501
+ Params: { keystoreId: string; keyId: string }
502
+ }>,
503
+ reply: FastifyReply
504
+ ): Promise<FastifyReply> {
505
+ const requestName = 'Get Key Description'
506
+ const { keystoreId, keyId } = request.params
507
+
508
+ const { config } = await fetchKeystoreAndVerify({
509
+ request,
510
+ keystoreId,
511
+ allowedAction: 'read',
512
+ requestName,
513
+ allowTargetAttenuation: true
514
+ })
515
+ const record = await fetchKeyRecord({
516
+ request,
517
+ keystoreId,
518
+ keyId,
519
+ requestName
520
+ })
521
+
522
+ return reply.send(
523
+ describeKmsKey({ key: record.key, controller: config.controller })
524
+ )
525
+ }
526
+
527
+ /**
528
+ * GET /kms/keystores/:keystoreId/keys
529
+ * List Keys (a fork extension beyond upstream webkms-switch). Enumerates the
530
+ * keystore's public key descriptions -- the Get Key Description projection
531
+ * per key (`describeKmsKey`: allowlisted public fields, the keystore's live
532
+ * controller, alias re-applied; never a secret field), plus `keyUrl`, the
533
+ * key's canonical invocation URL (which the alias override erases from `id`
534
+ * for precisely the keys a recovery client must rediscover) --
535
+ * sorted by local id and paginated (`KEY_LIST_LIMIT` per page, an opaque
536
+ * `cursor` following the standard convention). Capability-verified against the
537
+ * keystore's controller (`read`), with the `<keystoreId>/keys` URL accepted as
538
+ * an attenuated target (the same treatment Get Key gives the key URL). An
539
+ * empty keystore returns `{ results: [] }`, not a 404. Responds `{ results }`,
540
+ * plus a `next` cursor URL when a further page follows.
541
+ *
542
+ * @param request {import('fastify').FastifyRequest}
543
+ * @param reply {import('fastify').FastifyReply}
544
+ * @returns {Promise<FastifyReply>}
545
+ */
546
+ static async list(
547
+ request: FastifyRequest<{
548
+ Params: { keystoreId: string }
549
+ Querystring: { cursor?: string }
550
+ }>,
551
+ reply: FastifyReply
552
+ ): Promise<FastifyReply> {
553
+ const requestName = 'List Keys'
554
+ const { keystoreId } = request.params
555
+ const { cursor } = request.query
556
+ const { storage } = request.server
557
+
558
+ // The `?cursor` pagination parameter rides the signed request URL, so
559
+ // verification runs in target-query mode (as List Keystores / List
560
+ // Collection do). Authorization runs before any cursor validation below, so
561
+ // an under-authorized caller gets the merged 404 -- never `invalid-cursor`.
562
+ const { config } = await fetchKeystoreAndVerify({
563
+ request,
564
+ keystoreId,
565
+ allowedAction: 'read',
566
+ requestName,
567
+ allowTargetAttenuation: true,
568
+ allowTargetQuery: true
569
+ })
570
+
571
+ // The backend returns every record sorted by local id; key records are
572
+ // insert-once and immutable, so the cursor's `after` names a stable
573
+ // position -- resume from just past it (falling back to a keyset compare
574
+ // should the id somehow be absent). The cursor is opaque and validated
575
+ // here (400 `invalid-cursor` on a malformed token).
576
+ const keys = await storage.listKeys({ keystoreId })
577
+ let remaining = keys
578
+ if (cursor !== undefined) {
579
+ const { after } = decodeCursor(cursor)
580
+ const index = keys.findIndex(entry => entry.localId === after)
581
+ remaining =
582
+ index === -1
583
+ ? keys.filter(entry => entry.localId > after)
584
+ : keys.slice(index + 1)
585
+ }
586
+
587
+ const hasMore = remaining.length > KEY_LIST_LIMIT
588
+ const page = remaining.slice(0, KEY_LIST_LIMIT)
589
+ // Project directly from the stored record -- NO decrypt. The description
590
+ // reads only public fields, which stay plaintext at rest even under the
591
+ // record cipher (the `encrypted` envelope replaces only the secret subset).
592
+ // So listing never unwraps a key: it does not bulk-materialize private
593
+ // material in memory, and it still enumerates keys whose KEK is rotated out
594
+ // or whose envelope is corrupt -- the recovery-path robustness this endpoint
595
+ // exists for. (Contrast `fetchKeyRecord`, which must decrypt: get/operation
596
+ // need the secret.)
597
+ // Each entry additionally carries `keyUrl`, the key's canonical invocation
598
+ // URL. The alias override rewrites the description's `id`, erasing the one
599
+ // handle (`<keystoreId>/keys/<localId>`) a recovery client needs to invoke
600
+ // sign -- and a lister, unlike a Get Key caller, does not already hold it.
601
+ // Always stamped (aliased or not), so consumers need no fallback logic; it
602
+ // discloses only addressing an authorized lister already has, never key
603
+ // material.
604
+ const results = page.map(({ localId, record }) => ({
605
+ ...describeKmsKey({ key: record.key, controller: config.controller }),
606
+ keyUrl: `${config.id}/keys/${localId}`
607
+ }))
608
+
609
+ let next: string | undefined
610
+ if (hasMore) {
611
+ const lastId = page[page.length - 1]!.localId
612
+ next = `${kmsKeysPath({ keystoreId })}?cursor=${encodeCursor(lastId)}`
613
+ }
614
+
615
+ return reply.send({ results, ...(next !== undefined && { next }) })
616
+ }
617
+ }