@@ -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,106 @@ 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
270
+ ```
271
+
272
+ The current emulator notary (create-statement) implementation will sign
273
+ statements using a generated ephemeral key or a key we provide via the
274
+ ` --private-key-pem ` argument.
275
+
276
+ Since we need to export the key for verification by the policy engine, we will
277
+ first generate it using ` ssh-keygen ` .
278
+
279
+ ``` console
280
+ $ export ISSUER_PORT=" 9000" \
281
+ && export ISSUER_URL="http://localhost:${ISSUER_PORT}" \
282
+ && ssh-keygen -q -f /dev/stdout -t ecdsa -b 384 -N '' -I $RANDOM <<<y 2>/dev/null | python -c 'import sys; from cryptography.hazmat.primitives import serialization; print(serialization.load_ssh_private_key(sys.stdin.buffer.read(), password=None).private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()).decode().rstrip())' > private-key.pem \
283
+ && scitt-emulator client create-claim \
284
+ --private-key-pem private-key.pem \
285
+ --issuer "${ISSUER_URL}" \
286
+ --subject "solar" \
287
+ --content-type application/json \
288
+ --payload '{"sun": "yellow"}' \
289
+ --out claim.cose
290
+ ```
291
+
292
+ The core of policy engine we implemented in ` jsonschema_validator.py ` will
293
+ verify the COSE message generated using the public portion of the notary's key.
294
+ We've implemented two possible styles of key resolution. Both of them require
295
+ resolution of public keys via an HTTP server.
296
+
297
+ Let's start the HTTP server now, we'll populate the needed files in the
298
+ sections corresponding to each resolution style.
299
+
300
+ ``` console
301
+ $ python -m http.server " ${ISSUER_PORT} " &
302
+ $ python_http_server_pid=$!
303
+ ```
304
+
305
+ ### SSH ` authorized_keys ` style notary public key resolution
306
+
307
+ Keys are discovered via making an HTTP GET request to the URL given by the
308
+ ` issuer ` parameter via the ` web ` DID method and de-serializing the SSH
309
+ public keys found within the response body.
310
+
311
+ Start an HTTP server with an SSH public key served at the root.
312
+
313
+ ``` console
314
+ $ cat private-key.pem | ssh-keygen -f /dev/stdin -y | tee index.html
151
315
```
152
316
153
- Create claim from allowed issuer (` .org ` ) and from non-allowed (` .com ` ).
317
+ ### OpenID Connect token style notary public key resolution
318
+
319
+ Keys are discovered two part resolution of HTTP paths relative to the issuer
320
+
321
+ ` /.well-known/openid-configuration ` path is requested via HTTP GET. The
322
+ response body is parsed as JSON and the value of the ` jwks_uri ` key is
323
+ requested via HTTP GET.
324
+
325
+ ` /.well-known/jwks ` (is typically the value of ` jwks_uri ` ) path is requested
326
+ via HTTP GET. The response body is parsed as JSON. Public keys are loaded
327
+ from the value of the ` keys ` key which stores an array of JSON Web Key (JWK)
328
+ style serializations.
329
+
330
+ ``` console
331
+ $ mkdir -p .well-known/
332
+ $ cat > .well-known/openid-configuration << EOF
333
+ {
334
+ "issuer": "${ISSUER_URL}",
335
+ "jwks_uri": "${ISSUER_URL}/.well-known/jwks",
336
+ "response_types_supported": ["id_token"],
337
+ "claims_supported": ["sub", "aud", "exp", "iat", "iss"],
338
+ "id_token_signing_alg_values_supported": ["ES384"],
339
+ "scopes_supported": ["openid"]
340
+ }
341
+ EOF
342
+ $ 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
343
+ {
344
+ "keys": [
345
+ {
346
+ "crv": "P-384",
347
+ "kid": "y96luxaBaw6FeWVEMti_iqLWPSYk8cKLzZG8X45PA2k",
348
+ "kty": "EC",
349
+ "use": "sig",
350
+ "x": "ZQazDzYmcMHF5Dstkbw7SwWvR_oXQHFS-TLppri-0xDby8TmCpzHyr6TH03CLBxj",
351
+ "y": "lsIbRskEv06Rf0vttkB3vpXdZ-a50ck74MVyRwOvN55P4s8usQAm3PY1KnAgWtHF"
352
+ }
353
+ ]
354
+ }
355
+ ```
356
+
357
+ Attempt to submit the statement we created. You should see that due to our
358
+ current ` allowlist.schema.json ` the Transparency Service denied the insertion
359
+ of the statement into the log.
154
360
155
361
``` 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
362
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
159
363
Traceback (most recent call last):
160
364
File "/home/alice/.local/bin/scitt-emulator", line 33, in <module>
@@ -174,10 +378,27 @@ Failed validating 'enum' in schema['properties']['issuer']:
174
378
175
379
On instance['issuer']:
176
380
'did:web:example.com'
381
+ ```
382
+
383
+ Modify the allowlist to ensure that our issuer, aka our local HTTP server with
384
+ our keys, is set to be the allowed issuer.
385
+
386
+ ``` console
387
+ $ export allowlist=" $( cat allowlist.schema.json) " && \
388
+ jq '.properties.issuer.enum[0] = env.ISSUER_URL' <(echo "${allowlist}") \
389
+ | tee allowlist.schema.json
390
+ ```
177
391
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
392
+ Submit the statement from the issuer we just added to the allowlist.
393
+
394
+ ``` console
180
395
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
181
396
Claim registered with entry ID 1
182
397
Receipt written to claim.receipt.cbor
183
398
```
399
+
400
+ Stop the server that serves the public keys
401
+
402
+ ``` console
403
+ $ kill $python_http_server_pid
404
+ ```
0 commit comments