Skip to content

Commit 3d1020b

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 74c53f6 commit 3d1020b

File tree

3 files changed

+355
-56
lines changed

3 files changed

+355
-56
lines changed

docs/registration_policies.md

Lines changed: 268 additions & 34 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 1s scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
2323
Service parameters: workspace/service_parameters.json
2424
^C
2525
```
@@ -84,43 +84,171 @@ import os
8484
import sys
8585
import json
8686
import pathlib
87+
import unittest
8788
import traceback
89+
import contextlib
90+
import urllib.parse
91+
import urllib.request
8892

93+
import jwt
8994
import cbor2
95+
import cwt
96+
import cwt.algs.ec2
9097
import pycose
98+
import pycose.keys.ec2
99+
from pycose.messages import Sign1Message
91100
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
95102

96-
claim = sys.stdin.buffer.read()
103+
# TODO Remove this once we have a example flow for proper key verification
104+
import jwcrypto.jwk
97105

98-
msg = CoseMessage.decode(claim)
106+
from scitt_emulator.scitt import ClaimInvalidError, CWTClaims
99107

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")
104108

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+
]
108117
)
109118

110-
SCHEMA = json.loads(pathlib.Path(os.environ["SCHEMA_PATH"]).read_text())
111119

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",
120227
)
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()
124252
```
125253

126254
We'll create a small wrapper to serve in place of a more fully featured policy
@@ -140,21 +268,110 @@ echo ${CLAIM_PATH}
140268
Example running allowlist check and enforcement.
141269

142270
```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) {} \;'
145273
```
146274

147275
Also ensure you restart the server with the new config we edited.
148276

149277
```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
151299
```
152300

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

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

175392
On instance['issuer']:
176393
'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.
177406

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
180408
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
181409
Claim registered with entry ID 1
182410
Receipt written to claim.receipt.cbor
183411
```
412+
413+
Stop the server that serves the public keys
414+
415+
```console
416+
$ kill $python_http_server_pid
417+
```

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)