Skip to content
This repository was archived by the owner on Nov 22, 2024. It is now read-only.

Commit fa1169d

Browse files
authored
Update statement creation to rev a4645e4bc3e78ad5cfd9f8347c7e0ac8267c1079 of SCITT arch and implement verification of statements (#39)
* create statement: As standalone file for rev a4645e4bc3e78ad5cfd9f8347c7e0ac8267c1079 of SCITT arch Related: ietf-wg-scitt/draft-ietf-scitt-architecture@a4645e4 Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * scitt: create_claim: Update to rev a4645e4bc3e78ad5cfd9f8347c7e0ac8267c1079 of SCITT arch Related: ietf-wg-scitt/draft-ietf-scitt-architecture@a4645e4 Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * 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 <johnandersenpdx@gmail.com> * verify statement: As standalone file Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * create statement: Issuer as public key using did:key if not given Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * Remove unused imports $ git ls-files '*.py' | xargs autoflake --in-place --remove-all-unused-imports --ignore-init-module-imports Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * key loader format url referencing x509: Initial commit Asciinema: https://asciinema.org/a/627130 Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * tests: key loader format url referencing x509: In progress Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * key helpers: verification key to object: In progress Tests passing as of https://asciinema.org/a/627194 Asciinema: https://asciinema.org/a/627150 Asciinema: https://asciinema.org/a/627165 Asciinema: https://asciinema.org/a/627183 Asciinema: https://asciinema.org/a/627193 Asciinema: https://asciinema.org/a/627194 Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * docs: registration policies: x509 subject validation Asciinema: https://asciinema.org/a/627198 Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * key loader: x509: Remove Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * key loader: did: jwk: Ditch multibase did keys Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * test: docs: registration polcies: Ensure both ssh and oidc notary public key resolvers tested seperatly Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * key loader: did: web: SCITT SCRAPI transparency-configuration Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * create statement: Enable payload as bytes and creation of transparent statements (via receipts list) Signed-off-by: John Andersen <johnandersenpdx@gmail.com> * did helpers: Move url_to_did_web from tests Helpful for OIDC audience Signed-off-by: John Andersen <johnandersenpdx@gmail.com> --------- Signed-off-by: John Andersen <johnandersenpdx@gmail.com>
1 parent a2293ff commit fa1169d

24 files changed

+1126
-124
lines changed

.github/workflows/ci.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ jobs:
3838
with:
3939
activate-environment: scitt
4040
environment-file: environment.yml
41-
- run: python -m pytest
41+
- run: |
42+
python -m pip install -e .
43+
python -m pytest
4244
4345
ci-cd-build-and-push-image-container:
4446
name: CI/CD (container)

README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,14 @@ They can be used with the built-in server or an external service implementation.
9191

9292
```sh
9393
./scitt-emulator.sh client create-claim \
94-
--issuer did:web:example.com \
9594
--content-type application/json \
95+
--subject 'solar' \
9696
--payload '{"sun": "yellow"}' \
9797
--out claim.cose
9898
```
9999

100-
_**Note:** The emulator generates an ad-hoc key pair to sign the claim and does not verify claim signatures upon submission._
100+
_**Note:** The emulator generates an ad-hoc key pair to sign the claim if
101+
``--issuer`` and ``--public-key-pem`` are not given. See [Registration Policies](docs/registration_policies.md) docs for more deatiled examples_
101102

102103
2. View the signed claim by uploading `claim.cose` to one of the [CBOR or COSE Debugging Tools](#cose-and-cbor-debugging)
103104

docs/registration_policies.md

+190-35
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,66 @@ import os
8484
import sys
8585
import json
8686
import pathlib
87-
import traceback
87+
import unittest
8888

89-
import cbor2
89+
import cwt
9090
import pycose
91+
from pycose.messages import Sign1Message
9192
from jsonschema import validate, ValidationError
92-
from pycose.messages import CoseMessage, Sign1Message
9393

94-
from scitt_emulator.scitt import ClaimInvalidError, COSE_Headers_Issuer
94+
from scitt_emulator.scitt import ClaimInvalidError, CWTClaims
95+
from scitt_emulator.verify_statement import verify_statement
96+
from scitt_emulator.key_helpers import verification_key_to_object
97+
9598

96-
claim = sys.stdin.buffer.read()
99+
def main():
100+
claim = sys.stdin.buffer.read()
97101

98-
msg = CoseMessage.decode(claim)
102+
msg = Sign1Message.decode(claim, tag=True)
99103

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+
if pycose.headers.ContentType not in msg.phdr:
105+
raise ClaimInvalidError("Claim does not have a content type header parameter")
106+
if not msg.phdr[pycose.headers.ContentType].startswith("application/json"):
107+
raise TypeError(
108+
f"Claim content type does not start with application/json: {msg.phdr[pycose.headers.ContentType]!r}"
109+
)
104110

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}"
111+
verification_key = verify_statement(msg)
112+
unittest.TestCase().assertTrue(
113+
verification_key,
114+
"Failed to verify signature on statement",
108115
)
109116

110-
SCHEMA = json.loads(pathlib.Path(os.environ["SCHEMA_PATH"]).read_text())
117+
cwt_protected = cwt.decode(msg.phdr[CWTClaims], verification_key.cwt)
118+
issuer = cwt_protected[1]
119+
subject = cwt_protected[2]
111120

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,
121+
issuer_key_as_object = verification_key_to_object(verification_key)
122+
unittest.TestCase().assertTrue(
123+
issuer_key_as_object,
124+
"Failed to convert issuer key to JSON schema verifiable object",
120125
)
121-
except ValidationError as error:
122-
print(str(error), file=sys.stderr)
123-
sys.exit(1)
126+
127+
SCHEMA = json.loads(pathlib.Path(os.environ["SCHEMA_PATH"]).read_text())
128+
129+
try:
130+
validate(
131+
instance={
132+
"$schema": "https://schema.example.com/scitt-policy-engine-jsonschema.schema.json",
133+
"issuer": issuer,
134+
"issuer_key": issuer_key_as_object,
135+
"subject": subject,
136+
"claim": json.loads(msg.payload.decode()),
137+
},
138+
schema=SCHEMA,
139+
)
140+
except ValidationError as error:
141+
print(str(error), file=sys.stderr)
142+
sys.exit(1)
143+
144+
145+
if __name__ == "__main__":
146+
main()
124147
```
125148

126149
We'll create a small wrapper to serve in place of a more fully featured policy
@@ -140,21 +163,134 @@ echo ${CLAIM_PATH}
140163
Example running allowlist check and enforcement.
141164

142165
```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) {} \;'
166+
$ npm install nodemon && \
167+
DID_WEB_ASSUME_SCHEME=http 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) {} \;'
145168
```
146169

147170
Also ensure you restart the server with the new config we edited.
148171

149172
```console
150-
scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
173+
$ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro
174+
```
175+
176+
The current emulator notary (create-statement) implementation will sign
177+
statements using a generated ephemeral key or a key we provide via the
178+
`--private-key-pem` argument.
179+
180+
Since we need to export the key for verification by the policy engine, we will
181+
first generate it using `ssh-keygen`.
182+
183+
```console
184+
$ export ISSUER_PORT="9000" \
185+
&& export ISSUER_URL="http://localhost:${ISSUER_PORT}" \
186+
&& 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 \
187+
&& scitt-emulator client create-claim \
188+
--private-key-pem private-key.pem \
189+
--issuer "${ISSUER_URL}" \
190+
--subject "solar" \
191+
--content-type application/json \
192+
--payload '{"sun": "yellow"}' \
193+
--out claim.cose
151194
```
152195

153-
Create claim from allowed issuer (`.org`) and from non-allowed (`.com`).
196+
The core of policy engine we implemented in `jsonschema_validator.py` will
197+
verify the COSE message generated using the public portion of the notary's key.
198+
We've implemented two possible styles of key resolution. Both of them require
199+
resolution of public keys via an HTTP server.
200+
201+
Let's start the HTTP server now, we'll populate the needed files in the
202+
sections corresponding to each resolution style.
203+
204+
```console
205+
$ python -m http.server "${ISSUER_PORT}" &
206+
$ python_http_server_pid=$!
207+
```
208+
209+
### SSH `authorized_keys` style notary public key resolution
210+
211+
Keys are discovered via making an HTTP GET request to the URL given by the
212+
`issuer` parameter via the `web` DID method and de-serializing the SSH
213+
public keys found within the response body.
214+
215+
GitHub exports a users authentication keys at https://github.com/username.keys
216+
Leveraging this URL as an issuer `did:web:github.com:username.keys` with the
217+
following pattern would enable a GitHub user to act as a SCITT notary.
218+
219+
Start an HTTP server with an SSH public key served at the root.
220+
221+
```console
222+
$ cat private-key.pem | ssh-keygen -f /dev/stdin -y | tee index.html
223+
```
224+
225+
### OpenID Connect token style notary public key resolution
226+
227+
Keys are discovered two part resolution of HTTP paths relative to the issuer
228+
229+
`/.well-known/openid-configuration` path is requested via HTTP GET. The
230+
response body is parsed as JSON and the value of the `jwks_uri` key is
231+
requested via HTTP GET.
232+
233+
`/.well-known/jwks` (is typically the value of `jwks_uri`) path is requested
234+
via HTTP GET. The response body is parsed as JSON. Public keys are loaded
235+
from the value of the `keys` key which stores an array of JSON Web Key (JWK)
236+
style serializations.
237+
238+
```console
239+
$ mkdir -p .well-known/
240+
$ cat > .well-known/openid-configuration <<EOF
241+
{
242+
"issuer": "${ISSUER_URL}",
243+
"jwks_uri": "${ISSUER_URL}/.well-known/jwks",
244+
"response_types_supported": ["id_token"],
245+
"claims_supported": ["sub", "aud", "exp", "iat", "iss"],
246+
"id_token_signing_alg_values_supported": ["ES384"],
247+
"scopes_supported": ["openid"]
248+
}
249+
EOF
250+
$ 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
251+
{
252+
"keys": [
253+
{
254+
"crv": "P-384",
255+
"kid": "y96luxaBaw6FeWVEMti_iqLWPSYk8cKLzZG8X45PA2k",
256+
"kty": "EC",
257+
"use": "sig",
258+
"x": "ZQazDzYmcMHF5Dstkbw7SwWvR_oXQHFS-TLppri-0xDby8TmCpzHyr6TH03CLBxj",
259+
"y": "lsIbRskEv06Rf0vttkB3vpXdZ-a50ck74MVyRwOvN55P4s8usQAm3PY1KnAgWtHF"
260+
}
261+
]
262+
}
263+
```
264+
265+
### SCITT SRCAPI transparency configuration public key resolution
266+
267+
Keys are discovered via making an HTTP GET request to the URL given by the
268+
`issuer` parameter with `/.well-known/transparency-configuration` as the path
269+
component. Public keys found within the response body's JSON `jwks.keys` array.
270+
271+
- [`https://transparency.example/.well-known/transparency-configuration`](https://ietf-wg-scitt.github.io/draft-ietf-scitt-scrapi/draft-ietf-scitt-scrapi.html#name-transparency-configuration)
272+
273+
To use this method of resolution create the statement using the FQDN of the
274+
SCITT SCRAPI service as the issuer. Also ensure you use it's private key to
275+
sign.
276+
277+
```console
278+
$ scitt-emulator client create-claim \
279+
--private-key-pem workspace/storage/service_private_key.pem \
280+
--issuer "http://localhost:8000" \
281+
--subject "solar" \
282+
--content-type application/json \
283+
--payload '{"sun": "yellow"}' \
284+
--out claim.cose
285+
```
286+
287+
### Policy engine executing allowlist policy on denied issuer
288+
289+
Attempt to submit the statement we created. You should see that due to our
290+
current `allowlist.schema.json` the Transparency Service denied the insertion
291+
of the statement into the log.
154292

155293
```console
156-
$ scitt-emulator client create-claim --issuer did:web:example.com --content-type application/json --payload '{"sun": "yellow"}' --out claim.cose
157-
A COSE-signed Claim was written to: claim.cose
158294
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
159295
Traceback (most recent call last):
160296
File "/home/alice/.local/bin/scitt-emulator", line 33, in <module>
@@ -174,10 +310,29 @@ Failed validating 'enum' in schema['properties']['issuer']:
174310

175311
On instance['issuer']:
176312
'did:web:example.com'
313+
```
314+
315+
### Policy engine executing allowlist policy on allowed issuer
316+
317+
Modify the allowlist to ensure that our issuer, aka our local HTTP server with
318+
our keys, is set to be the allowed issuer.
319+
320+
```console
321+
$ export allowlist="$(cat allowlist.schema.json)" && \
322+
jq '.properties.issuer.enum = [env.ISSUER_URL, "http://localhost:8000"]' <(echo "${allowlist}") \
323+
| tee allowlist.schema.json
324+
```
177325

178-
$ scitt-emulator client create-claim --issuer did:web:example.org --content-type application/json --payload '{"sun": "yellow"}' --out claim.cose
179-
A COSE signed Claim was written to: claim.cose
326+
Submit the statement from the issuer we just added to the allowlist.
327+
328+
```console
180329
$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor
181330
Claim registered with entry ID 1
182331
Receipt written to claim.receipt.cbor
183332
```
333+
334+
Stop the server that serves the public keys
335+
336+
```console
337+
$ kill $python_http_server_pid
338+
```

environment.yml

+1
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ dependencies:
3939
- jwcrypto==1.5.0
4040
- PyJWT==2.8.0
4141
- werkzeug==2.2.2
42+
- cwt==2.7.1

pytest.ini

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[pytest]
2+
# https://docs.pytest.org/en/7.1.x/how-to/doctest.html#using-doctest-options
3+
doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
4+
# Alternatively, options can be enabled by an inline comment in the doc test itself:
5+
# >>> something_that_raises() # doctest: +IGNORE_EXCEPTION_DETAIL
6+
# Traceback (most recent call last):
7+
# ValueError: ...
8+
addopts = --doctest-modules

scitt_emulator/ccf.py

+14
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from pathlib import Path
66
from hashlib import sha256
77
import datetime
8+
import pathlib
89
import json
910

11+
import jwcrypto.jwk
1012
from cryptography.hazmat.primitives.asymmetric import ec, utils
1113
from cryptography.hazmat.primitives.serialization import (
1214
Encoding,
@@ -72,6 +74,18 @@ def initialize_service(self):
7274
json.dump(self.service_parameters, f)
7375
print(f"Service parameters written to {self.service_parameters_path}")
7476

77+
def keys_as_jwks(self):
78+
key = jwcrypto.jwk.JWK()
79+
key_bytes = pathlib.Path(self._service_private_key_path).read_bytes()
80+
key.import_from_pem(key_bytes)
81+
return [
82+
{
83+
**key.export_public(as_dict=True),
84+
"use": "sig",
85+
"kid": key.thumbprint(),
86+
}
87+
]
88+
7589
def create_receipt_contents(self, countersign_tbi: bytes, entry_id: str):
7690
# Load service private key and certificate
7791
with open(self._service_private_key_path, "rb") as f:

scitt_emulator/client.py

+2-15
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import httpx
1010

11-
import scitt_emulator.scitt as scitt
11+
from scitt_emulator import create_statement
1212
from scitt_emulator.tree_algs import TREE_ALGS
1313

1414
DEFAULT_URL = "http://127.0.0.1:8000"
@@ -72,10 +72,6 @@ def post(self, *args, **kwargs):
7272
return self._request("POST", *args, **kwargs)
7373

7474

75-
def create_claim(issuer: str, content_type: str, payload: str, claim_path: Path):
76-
scitt.create_claim(claim_path, issuer, content_type, payload)
77-
78-
7975
def submit_claim(
8076
url: str,
8177
claim_path: Path,
@@ -170,16 +166,7 @@ def cli(fn):
170166
parser = fn(description="Execute client commands")
171167
sub = parser.add_subparsers(dest="cmd", help="Command to execute", required=True)
172168

173-
p = sub.add_parser("create-claim", description="Create a fake SCITT claim")
174-
p.add_argument("--out", required=True, type=Path)
175-
p.add_argument("--issuer", required=True, type=str)
176-
p.add_argument("--content-type", required=True, type=str)
177-
p.add_argument("--payload", required=True, type=str)
178-
p.set_defaults(
179-
func=lambda args: scitt.create_claim(
180-
args.out, args.issuer, args.content_type, args.payload
181-
)
182-
)
169+
create_statement.cli(sub.add_parser)
183170

184171
p = sub.add_parser(
185172
"submit-claim", description="Submit a SCITT claim and retrieve the receipt"

0 commit comments

Comments
 (0)