Skip to content

Commit e4b590b

Browse files
committed
docs: registration policies: CWT decode and COSESign1.verify_signature
- Working with SSH authorized_keys and OIDC style jwks - CWT decode - COSESign1.verify_signature - Working registration policy Signed-off-by: John Andersen <[email protected]>
1 parent 831bd3a commit e4b590b

File tree

3 files changed

+321
-38
lines changed

3 files changed

+321
-38
lines changed

docs/registration_policies.md

Lines changed: 234 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ The SCITT API emulator can deny entry based on presence of
1212
This is a simple way to enable evaluation of claims prior to submission by
1313
arbitrary policy engines which watch the workspace (fanotify, inotify, etc.).
1414

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)
1616

1717
Start the server
1818

1919
```console
2020
$ rm -rf workspace/
2121
$ 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
2323
Service parameters: workspace/service_parameters.json
2424
^C
2525
```
@@ -84,36 +84,155 @@ import os
8484
import sys
8585
import json
8686
import pathlib
87+
import unittest
8788
import traceback
89+
import contextlib
90+
import urllib.parse
8891

92+
import jwt
93+
import cwt
94+
import cwt.algs.ec2
8995
import cbor2
9096
import pycose
97+
98+
# TODO Remove this once we have a example flow for proper key verification
99+
import jwcrypto.jwk
91100
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
93104

94-
from scitt_emulator.scitt import ClaimInvalidError, COSE_Headers_Issuer
105+
from scitt_emulator.scitt import ClaimInvalidError, CWTClaims
95106

96107
claim = sys.stdin.buffer.read()
97108

98-
msg = CoseMessage.decode(claim)
109+
msg = Sign1Message.decode(claim, tag=True)
99110

100111
if pycose.headers.ContentType not in msg.phdr:
101112
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")
104113

105114
if not msg.phdr[pycose.headers.ContentType].startswith("application/json"):
106115
raise TypeError(
107116
f"Claim content type does not start with application/json: {msg.phdr[pycose.headers.ContentType]!r}"
108117
)
109118

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?
110228
SCHEMA = json.loads(pathlib.Path(os.environ["SCHEMA_PATH"]).read_text())
111229

112230
try:
113231
validate(
114232
instance={
115233
"$schema": "https://schema.example.com/scitt-policy-engine-jsonschema.schema.json",
116-
"issuer": msg.phdr[COSE_Headers_Issuer],
234+
"issuer": issuer,
235+
"subject": subject,
117236
"claim": json.loads(msg.payload.decode()),
118237
},
119238
schema=SCHEMA,
@@ -140,21 +259,103 @@ echo ${CLAIM_PATH}
140259
Example running allowlist check and enforcement.
141260

142261
```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) {} \;'
145264
```
146265

147266
Also ensure you restart the server with the new config we edited.
148267

149268
```console
150-
scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
269+
$ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
151270
```
152271

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.
154357

155358
```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
158359
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
159360
Traceback (most recent call last):
160361
File "/home/alice/.local/bin/scitt-emulator", line 33, in <module>
@@ -174,10 +375,27 @@ Failed validating 'enum' in schema['properties']['issuer']:
174375

175376
On instance['issuer']:
176377
'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+
```
177388

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
180392
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
181393
Claim registered with entry ID 1
182394
Receipt written to claim.receipt.cbor
183395
```
396+
397+
Stop the server that serves the public keys
398+
399+
```console
400+
$ kill $python_http_server_pid
401+
```

tests/test_cli.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33
import os
4+
import io
45
import json
56
import threading
67
import pytest
78
import jwt
89
import jwcrypto
9-
from flask import Flask, jsonify
10+
from flask import Flask, jsonify, send_file
1011
from werkzeug.serving import make_server
1112
from scitt_emulator import cli, server
1213
from scitt_emulator.oidc import OIDCAuthMiddleware
@@ -164,6 +165,22 @@ def create_flask_app_oidc_server(config):
164165
app.config.update(dict(DEBUG=True))
165166
app.config.update(config)
166167

168+
# TODO For testing ssh key style issuers, not OIDC related needs to be moved
169+
@app.route("/", methods=["GET"])
170+
def ssh_public_keys():
171+
from cryptography.hazmat.primitives import serialization
172+
return send_file(
173+
io.BytesIO(
174+
serialization.load_pem_public_key(
175+
app.config["key"].export_to_pem(),
176+
).public_bytes(
177+
encoding=serialization.Encoding.OpenSSH,
178+
format=serialization.PublicFormat.OpenSSH,
179+
)
180+
),
181+
mimetype="text/plain",
182+
)
183+
167184
@app.route("/.well-known/openid-configuration", methods=["GET"])
168185
def openid_configuration():
169186
return jsonify(

0 commit comments

Comments
 (0)