@@ -12,14 +12,14 @@ The SCITT API emulator can deny entry based on presence of
12
12
This is a simple way to enable evaluation of claims prior to submission by
13
13
arbitrary policy engines which watch the workspace (fanotify, inotify, etc.).
14
14
15
- [ ![ asciicast-of-simple-decoupled-file-based-policy-engine] ( https://asciinema.org/a/572766 .svg )] ( https://asciinema.org/a/572766 )
15
+ [ ![ asciicast-of-simple-decoupled-file-based-policy-engine] ( https://asciinema.org/a/620587 .svg )] ( https://asciinema.org/a/620587 )
16
16
17
17
Start the server
18
18
19
19
``` console
20
20
$ rm -rf workspace/
21
21
$ mkdir -p workspace/storage/operations
22
- $ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
22
+ $ timeout 0.5s scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
23
23
Service parameters: workspace/service_parameters.json
24
24
^C
25
25
```
@@ -84,36 +84,155 @@ import os
84
84
import sys
85
85
import json
86
86
import pathlib
87
+ import unittest
87
88
import traceback
89
+ import contextlib
90
+ import urllib.parse
88
91
92
+ import jwt
93
+ import cwt
94
+ import cwt.algs.ec2
89
95
import cbor2
90
96
import pycose
97
+
98
+ # TODO Remove this once we have a example flow for proper key verification
99
+ import jwcrypto.jwk
91
100
from jsonschema import validate, ValidationError
92
- from pycose.messages import CoseMessage, Sign1Message
101
+ import pycose.keys.ec2
102
+ import cryptography.hazmat.primitives.serialization
103
+ from pycose.messages import Sign1Message
93
104
94
- from scitt_emulator.scitt import ClaimInvalidError, COSE_Headers_Issuer
105
+ from scitt_emulator.scitt import ClaimInvalidError, CWTClaims
95
106
96
107
claim = sys.stdin.buffer.read()
97
108
98
- msg = CoseMessage .decode(claim)
109
+ msg = Sign1Message .decode(claim, tag = True )
99
110
100
111
if pycose.headers.ContentType not in msg.phdr:
101
112
raise ClaimInvalidError(" Claim does not have a content type header parameter" )
102
- if COSE_Headers_Issuer not in msg.phdr:
103
- raise ClaimInvalidError(" Claim does not have an issuer header parameter" )
104
113
105
114
if not msg.phdr[pycose.headers.ContentType].startswith(" application/json" ):
106
115
raise TypeError (
107
116
f " Claim content type does not start with application/json: { msg.phdr[pycose.headers.ContentType]!r } "
108
117
)
109
118
119
+ # TODO Whatever the opisite of COSESign1 is
120
+
121
+ # Figure out what the issuer is
122
+ cwt_cose_loads = cwt.cose.COSE()._loads
123
+ cwt_unverified_protected = cwt_cose_loads(cwt_cose_loads(msg.phdr[CWTClaims]).value[2 ])
124
+ unverified_issuer = cwt_unverified_protected[1 ]
125
+
126
+ def did_web_to_url (did_web_string , scheme = os.environ.get(" DID_WEB_ASSUME_SCHEME" , " https" )):
127
+ return " /" .join(
128
+ [
129
+ f " { scheme} :/ " ,
130
+ * [urllib.parse.unquote(i) for i in did_web_string.split(" :" )[2 :]],
131
+ ]
132
+ )
133
+
134
+ if unverified_issuer.startswith(" did:web:" ):
135
+ unverified_issuer = did_web_to_url(unverified_issuer)
136
+
137
+ # TODO Should we use audiance? I think no, just want to make sure we've
138
+ # documented why thought if not. No usage makes sense to me becasue we don't
139
+ # know the intended audiance, it could be federated into multiple TS
140
+
141
+ # TODO Can you just pass a whole public key as an issuer?
142
+
143
+ # Load keys from issuer
144
+ jwk_keys = []
145
+
146
+ import urllib.request
147
+ import urllib.parse
148
+
149
+ # TODO did:web: -> URL
150
+ from cryptography.hazmat.primitives import serialization
151
+
152
+ cryptography_ssh_keys = []
153
+ if " ://" in unverified_issuer and not unverified_issuer.startswith(" file://" ):
154
+ # TODO Logging for URLErrors
155
+ # Check if OIDC issuer
156
+ unverified_issuer_parsed_url = urllib.parse.urlparse(unverified_issuer)
157
+ openid_configuration_url = unverified_issuer_parsed_url._replace(
158
+ path = " /.well-known/openid-configuration" ,
159
+ ).geturl()
160
+ with contextlib.suppress(urllib.request.URLError):
161
+ with urllib.request.urlopen(openid_configuration_url) as response:
162
+ if response.status == 200 :
163
+ openid_configuration = json.loads(response.read())
164
+ jwks_uri = openid_configuration[" jwks_uri" ]
165
+ with urllib.request.urlopen(jwks_uri) as response:
166
+ if response.status == 200 :
167
+ jwks = json.loads(response.read())
168
+ for jwk_key_as_dict in jwks[" keys" ]:
169
+ jwk_key_as_string = json.dumps(jwk_key_as_dict)
170
+ jwk_keys.append(
171
+ jwcrypto.jwk.JWK .from_json(jwk_key_as_string),
172
+ )
173
+
174
+ # Try loading ssh keys. Example: https://github.com/username.keys
175
+ with contextlib.suppress(urllib.request.URLError):
176
+ with urllib.request.urlopen(unverified_issuer) as response:
177
+ while line := response.readline():
178
+ with contextlib.suppress(
179
+ (ValueError , cryptography.exceptions.UnsupportedAlgorithm)
180
+ ):
181
+ cryptography_ssh_keys.append(
182
+ cryptography.hazmat.primitives.serialization.load_ssh_public_key(
183
+ line
184
+ )
185
+ )
186
+
187
+ for cryptography_ssh_key in cryptography_ssh_keys:
188
+ jwk_keys.append(
189
+ jwcrypto.jwk.JWK .from_pem(
190
+ cryptography_ssh_key.public_bytes(
191
+ encoding = serialization.Encoding.PEM ,
192
+ format = serialization.PublicFormat.SubjectPublicKeyInfo,
193
+ )
194
+ )
195
+ )
196
+
197
+ cwt_cose_keys = []
198
+ pycose_cose_keys = []
199
+
200
+ for jwk_key in jwk_keys:
201
+ cwt_cose_key = cwt.COSEKey.from_pem(
202
+ jwk_key.export_to_pem(),
203
+ kid = jwk_key.thumbprint(),
204
+ )
205
+ cwt_cose_keys.append(cwt_cose_key)
206
+ cwt_ec2_key_as_dict = cwt_cose_key.to_dict()
207
+ pycose_cose_key = pycose.keys.ec2.EC2Key.from_dict(cwt_ec2_key_as_dict)
208
+ pycose_cose_keys.append(pycose_cose_key)
209
+
210
+ verify_signature = False
211
+ for pycose_cose_key in pycose_cose_keys:
212
+ with contextlib.suppress(Exception ):
213
+ msg.key = pycose_cose_key
214
+ verify_signature = msg.verify_signature()
215
+ if verify_signature:
216
+ break
217
+
218
+ unittest.TestCase().assertTrue(
219
+ verify_signature,
220
+ " Failed to verify signature on statement" ,
221
+ )
222
+
223
+ cwt_protected = cwt.decode(msg.phdr[CWTClaims], cwt_cose_keys)
224
+ issuer = cwt_protected[1 ]
225
+ subject = cwt_protected[2 ]
226
+
227
+ # TODO Validate content type is JSON?
110
228
SCHEMA = json.loads(pathlib.Path(os.environ[" SCHEMA_PATH" ]).read_text())
111
229
112
230
try :
113
231
validate(
114
232
instance = {
115
233
" $schema" : " https://schema.example.com/scitt-policy-engine-jsonschema.schema.json" ,
116
- " issuer" : msg.phdr[COSE_Headers_Issuer ],
234
+ " issuer" : issuer,
235
+ " subject" : subject,
117
236
" claim" : json.loads(msg.payload.decode()),
118
237
},
119
238
schema = SCHEMA ,
@@ -140,21 +259,103 @@ echo ${CLAIM_PATH}
140
259
Example running allowlist check and enforcement.
141
260
142
261
``` console
143
- npm install -g nodemon
144
- nodemon -e .cose --exec 'find workspace/storage/operations -name \*.cose -exec nohup sh -xe policy_engine.sh $(cat workspace/service_parameters.json | jq -r .insertPolicy) {} \;'
262
+ $ npm install nodemon && \
263
+ node_modules/.bin/ nodemon -e .cose --exec 'find workspace/storage/operations -name \*.cose -exec nohup sh -xe policy_engine.sh $(cat workspace/service_parameters.json | jq -r .insertPolicy) {} \;'
145
264
```
146
265
147
266
Also ensure you restart the server with the new config we edited.
148
267
149
268
``` console
150
- scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
269
+ $ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
151
270
```
152
271
153
- Create claim from allowed issuer (` .org ` ) and from non-allowed (` .com ` ).
272
+ The current emulator notary (create-statement) implementation will sign
273
+ statements using a generated key or a key we provide via the ` --private-key-pem `
274
+ argument. If we provide the ` --private-key-pem ` argument but the key at the
275
+ given path does not exist, the generated key will be written out to that path.
276
+
277
+ ``` console
278
+ $ export ISSUER_PORT=" 9000" && \
279
+ export ISSUER_URL="http://localhost:${ISSUER_PORT}"
280
+ $ scitt-emulator client create-claim \
281
+ --private-key-pem private-key.pem \
282
+ --issuer "${ISSUER_URL}" \
283
+ --subject "solar" \
284
+ --content-type application/json \
285
+ --payload '{"sun": "yellow"}' \
286
+ --out claim.cose
287
+ ```
288
+
289
+ The core of policy engine we implemented in ` jsonschema_validator.py ` will
290
+ verify the COSE message generated using the public portion of the notary's key.
291
+ We've implemented two possible styles of key resolution. Both of them require
292
+ resolution of public keys via an HTTP server.
293
+
294
+ Let's start the HTTP server now, we'll populate the needed files in the
295
+ sections corresponding to each resolution style.
296
+
297
+ ``` console
298
+ $ python -m http.server " ${ISSUER_PORT} " &
299
+ $ python_http_server_pid=$!
300
+ ```
301
+
302
+ ### SSH ` authorized_keys ` style notary public key resolution
303
+
304
+ Keys are discovered via making an HTTP GET request to the URL given by the
305
+ ` issuer ` parameter via the ` web ` DID method and de-serializing the SSH
306
+ public keys found within the response body.
307
+
308
+ Start an HTTP server with an SSH public key served at the root.
309
+
310
+ ``` console
311
+ $ cat private-key.pem | ssh-keygen -f /dev/stdin -y | tee index.html
312
+ ```
313
+
314
+ ### OpenID Connect token style notary public key resolution
315
+
316
+ Keys are discovered two part resolution of HTTP paths relative to the issuer
317
+
318
+ ` /.well-known/openid-configuration ` path is requested via HTTP GET. The
319
+ response body is parsed as JSON and the value of the ` jwks_uri ` key is
320
+ requested via HTTP GET.
321
+
322
+ ` /.well-known/jwks ` (is typically the value of ` jwks_uri ` ) path is requested
323
+ via HTTP GET. The response body is parsed as JSON. Public keys are loaded
324
+ from the value of the ` keys ` key which stores an array of JSON Web Key (JWK)
325
+ style serializations.
326
+
327
+ ``` console
328
+ $ mkdir -p .well-known/
329
+ $ cat > .well-known/openid-configuration << EOF
330
+ {
331
+ "issuer": "${ISSUER_URL}",
332
+ "jwks_uri": "${ISSUER_URL}/.well-known/jwks",
333
+ "response_types_supported": ["id_token"],
334
+ "claims_supported": ["sub", "aud", "exp", "iat", "iss"],
335
+ "id_token_signing_alg_values_supported": ["ES384"],
336
+ "scopes_supported": ["openid"]
337
+ }
338
+ EOF
339
+ $ cat private-key.pem | python -c ' import sys, json, jwcrypto.jwt; key = jwcrypto.jwt.JWK(); key.import_from_pem(sys.stdin.buffer.read()); print(json.dumps({"keys":[{**key.export_public(as_dict=True),"use": "sig","kid": key.thumbprint()}]}, indent=4, sort_keys=True))' | tee .well-known/jwks
340
+ {
341
+ "keys": [
342
+ {
343
+ "crv": "P-384",
344
+ "kid": "y96luxaBaw6FeWVEMti_iqLWPSYk8cKLzZG8X45PA2k",
345
+ "kty": "EC",
346
+ "use": "sig",
347
+ "x": "ZQazDzYmcMHF5Dstkbw7SwWvR_oXQHFS-TLppri-0xDby8TmCpzHyr6TH03CLBxj",
348
+ "y": "lsIbRskEv06Rf0vttkB3vpXdZ-a50ck74MVyRwOvN55P4s8usQAm3PY1KnAgWtHF"
349
+ }
350
+ ]
351
+ }
352
+ ```
353
+
354
+ Attempt to submit the statement we created. You should see that due to our
355
+ current ` allowlist.schema.json ` the Transparency Service denied the insertion
356
+ of the statement into the log.
154
357
155
358
``` console
156
- $ scitt-emulator client create-claim --issuer did:web:example.com --subject " solar" --content-type application/json --payload ' {"sun": "yellow"}' --out claim.cose
157
- A COSE-signed Claim was written to: claim.cose
158
359
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
159
360
Traceback (most recent call last):
160
361
File "/home/alice/.local/bin/scitt-emulator", line 33, in <module>
@@ -174,10 +375,27 @@ Failed validating 'enum' in schema['properties']['issuer']:
174
375
175
376
On instance['issuer']:
176
377
'did:web:example.com'
378
+ ```
379
+
380
+ Modify the allowlist to ensure that our issuer, aka our local HTTP server with
381
+ our keys, is set to be the allowed issuer.
382
+
383
+ ``` console
384
+ $ export allowlist=" $( cat allowlist.schema.json) " && \
385
+ jq '.properties.issuer.enum[0] = env.ISSUER_URL' <(echo "${allowlist}") \
386
+ | tee allowlist.schema.json
387
+ ```
177
388
178
- $ scitt-emulator client create-claim --issuer did:web:example.org --subject " solar" --content-type application/json --payload ' {"sun": "yellow"}' --out claim.cose
179
- A COSE signed Claim was written to: claim.cose
389
+ Submit the statement from the issuer we just added to the allowlist.
390
+
391
+ ``` console
180
392
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
181
393
Claim registered with entry ID 1
182
394
Receipt written to claim.receipt.cbor
183
395
```
396
+
397
+ Stop the server that serves the public keys
398
+
399
+ ``` console
400
+ $ kill $python_http_server_pid
401
+ ```
0 commit comments