Skip to content

Commit c62719a

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 7745b03 commit c62719a

File tree

3 files changed

+324
-38
lines changed

3 files changed

+324
-38
lines changed

docs/registration_policies.md

Lines changed: 237 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,106 @@ 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
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
151315
```
152316

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

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

175379
On instance['issuer']:
176380
'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+
```
177391

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
180395
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
181396
Claim registered with entry ID 1
182397
Receipt written to claim.receipt.cbor
183398
```
399+
400+
Stop the server that serves the public keys
401+
402+
```console
403+
$ kill $python_http_server_pid
404+
```

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)