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