diff --git a/fiftyone_pipeline_cloudrequestengine/readme.md b/fiftyone_pipeline_cloudrequestengine/readme.md index b8ab452..d2c71a0 100644 --- a/fiftyone_pipeline_cloudrequestengine/readme.md +++ b/fiftyone_pipeline_cloudrequestengine/readme.md @@ -19,6 +19,18 @@ This package uses the `engines` class created by the `fiftyone-pipeline-engines` * A `Cloud Request Engine` which calls the 51Degrees cloud service to fetch properties and metadata about them based on a provided resource key. Get a resource key at https://configure.51degrees.com/?utm_source=github&utm_medium=readme&utm_campaign=pipeline-python&utm_content=fiftyone_pipeline_cloudrequestengine-readme.md&utm_term=this-package-fiftyone_pipeline_cloudrequestengine * A `Cloud Engine` template which reads data from the Cloud Request Engine. +## Pointing the engine at another host + +The engine calls `https://cloud.51degrees.com/api/v4/` unless told +otherwise. Set the `FOD_CLOUD_API_URL` environment variable, or pass +`cloud_endpoint` in the engine settings, to the API base of another +host including the `/api/v4/` segment. A host other than +cloud.51degrees.com would be used to (a) use an on premise web server, +or (b) use a privately hosted version of the 51Degrees cloud for +performance reasons. This is the private hosting option of the cloud +service, and both run the same service, so code written against one +works unchanged against the other. + It is used by the cloud versions of the following 51Degrees engines: - [**fiftyone_devicedetection**](https://pypi.org/project/fiftyone-devicedetection/) - Get details about the devices accessing your web page diff --git a/fiftyone_pipeline_cloudrequestengine/src/fiftyone_pipeline_cloudrequestengine/cloudrequestengine.py b/fiftyone_pipeline_cloudrequestengine/src/fiftyone_pipeline_cloudrequestengine/cloudrequestengine.py index 3071aa3..4bbf9e9 100644 --- a/fiftyone_pipeline_cloudrequestengine/src/fiftyone_pipeline_cloudrequestengine/cloudrequestengine.py +++ b/fiftyone_pipeline_cloudrequestengine/src/fiftyone_pipeline_cloudrequestengine/cloudrequestengine.py @@ -76,6 +76,14 @@ def __init__(self, settings = {}): self.resource_key = settings["resource_key"] + # The endpoint is the cloud_endpoint setting, then the + # FOD_CLOUD_API_URL environment variable, then + # cloud.51degrees.com. A host other than cloud.51degrees.com + # would be used to (a) use an on premise web server, or (b) use + # a privately hosted version of the 51Degrees cloud for + # performance reasons. That is the private hosting option of + # the cloud service, and both run the same service, so callers + # work unchanged against either. if "cloud_endpoint" in settings: self.baseURL = settings["cloud_endpoint"] else: diff --git a/fiftyone_pipeline_did/examples/creator_context_web/examples-main.min.css b/fiftyone_pipeline_did/examples/creator_context_web/examples-main.min.css new file mode 100644 index 0000000..8007642 --- /dev/null +++ b/fiftyone_pipeline_did/examples/creator_context_web/examples-main.min.css @@ -0,0 +1 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}.a-size--10{width:.1615055829rem;height:.1615055829rem}.a-size--9{width:.1938066995rem;height:.1938066995rem}.a-size--8{width:.2325680394rem;height:.2325680394rem}.a-size--7{width:.2790816472rem;height:.2790816472rem}.a-size--6{width:.3348979767rem;height:.3348979767rem}.a-size--5{width:.401877572rem;height:.401877572rem}.a-size--4{width:.4822530864rem;height:.4822530864rem}.a-size--3{width:.5787037037rem;height:.5787037037rem}.a-size--2{width:.6944444444rem;height:.6944444444rem}.a-size--1{width:.8333333333rem;height:.8333333333rem}.a-size-0{width:1rem;height:1rem}.a-size-1{width:1.2rem;height:1.2rem}.a-size-2{width:1.44rem;height:1.44rem}.a-size-3{width:1.728rem;height:1.728rem}.a-size-4{width:2.0736rem;height:2.0736rem}.a-size-5{width:2.48832rem;height:2.48832rem}.a-size-6{width:2.985984rem;height:2.985984rem}.a-size-7{width:3.5831808rem;height:3.5831808rem}.a-size-8{width:4.29981696rem;height:4.29981696rem}.a-size-9{width:5.159780352rem;height:5.159780352rem}.a-size-10{width:6.1917364224rem;height:6.1917364224rem}.a-size-11{width:7.4300837069rem;height:7.4300837069rem}.a-size-12{width:8.9161004483rem;height:8.9161004483rem}.a-size-13{width:10.6993205379rem;height:10.6993205379rem}.a-size-14{width:12.8391846455rem;height:12.8391846455rem}.a-size-15{width:15.4070215746rem;height:15.4070215746rem}.a-size-16{width:18.4884258895rem;height:18.4884258895rem}.a-size-17{width:22.1861110674rem;height:22.1861110674rem}.a-size-18{width:26.6233332809rem;height:26.6233332809rem}.a-size-19{width:31.9479999371rem;height:31.9479999371rem}.a-size-20{width:38.3375999245rem;height:38.3375999245rem}.a-font-size--10{font-size:.1615055829rem}.a-font-size--9{font-size:.1938066995rem}.a-font-size--8{font-size:.2325680394rem}.a-font-size--7{font-size:.2790816472rem}.a-font-size--6{font-size:.3348979767rem}.a-font-size--5{font-size:.401877572rem}.a-font-size--4{font-size:.4822530864rem}.a-font-size--3{font-size:.5787037037rem}.a-font-size--2{font-size:.6944444444rem}.a-font-size--1{font-size:.8333333333rem}.a-font-size-0{font-size:1rem}.a-font-size-1{font-size:1.2rem}.a-font-size-2{font-size:1.44rem}.a-font-size-3{font-size:1.728rem}.a-font-size-4{font-size:2.0736rem}.a-font-size-5{font-size:2.48832rem}.a-font-size-6{font-size:2.985984rem}.a-font-size-7{font-size:3.5831808rem}.a-font-size-8{font-size:4.29981696rem}.a-font-size-9{font-size:5.159780352rem}.a-font-size-10{font-size:6.1917364224rem}.a-font-size-11{font-size:7.4300837069rem}.a-font-size-12{font-size:8.9161004483rem}.a-font-size-13{font-size:10.6993205379rem}.a-font-size-14{font-size:12.8391846455rem}.a-font-size-15{font-size:15.4070215746rem}.a-font-size-16{font-size:18.4884258895rem}.a-font-size-17{font-size:22.1861110674rem}.a-font-size-18{font-size:26.6233332809rem}.a-font-size-19{font-size:31.9479999371rem}.a-font-size-20{font-size:38.3375999245rem}.a-font--default{font-family:Arial,sans-serif}.a-font--code{font-family:Consolas,monospace}.b-text--hidden{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.b-text--default{font-family:Arial,sans-serif;font-size:1rem;color:#252525}.b-text--count{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#9b9b9b}.b-text--count-pill{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#9b9b9b;border:1px solid #BAC82A;border-radius:28px;padding:.093463879rem .4822530864rem;color:#252525;background-color:#fff;display:inline}.b-text--property-pill{font-family:Arial,sans-serif;font-size:.8333333333rem;color:#646060}.b-text--heading-1{font-family:Arial,sans-serif;font-weight:700;font-size:1.44rem;color:#252525;margin:0 0 1.728rem}.b-text--heading-2{font-family:Arial,sans-serif;font-weight:700;font-size:1.2rem;color:#252525;margin:0 0 1.44rem}.b-text--heading-3{font-family:Arial,sans-serif;font-weight:700;font-size:1rem;color:#252525;margin:0 0 1.2rem}.b-text--code{font-family:Consolas,monospace;font-size:.8333333333rem;color:#646060}.b-text--code-snip{font-family:Consolas,monospace;font-size:.8333333333rem;color:#646060;background-color:#fff;border-radius:5px;border:1px solid #EAEAEA;padding:0 .2325680394rem}.b-text--heading-caps{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#252525;font-weight:400;text-transform:uppercase;letter-spacing:.5px}.b-text--copyright{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#252525}.b-text--progress{font-family:Arial,sans-serif;font-size:.8333333333rem}@media(min-width:769px){.b-text--progress{font-size:1rem}}.b-text--progress{color:#252525;letter-spacing:.7px;text-transform:uppercase;font-weight:700}.b-text--red{color:#cc2b27}body{font-family:Arial,sans-serif;font-size:1rem;color:#252525;margin:0;line-height:1.5}*{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;box-sizing:border-box}.b-link{transition:color .3s ease;color:#252525;text-decoration:underline}.b-link:active,.b-link:hover,.b-link:focus{color:#f5841f;text-decoration:underline}.b-link{display:inline-flex;align-items:center}.b-link img{margin:0 .5787037037rem}.b-link--property{font-family:Arial,sans-serif;font-size:.8333333333rem;color:#646060;color:#9b9b9b;border-bottom:2px dotted #EAEAEA;text-decoration:none}.b-link--property:active,.b-link--property:hover,.b-link--property:focus{color:#646060;border-bottom:2px dotted #646060;text-decoration:none}.b-link--dotted{color:#9b9b9b;border-bottom:2px dotted #EAEAEA;text-decoration:none}.b-link--dotted:active,.b-link--dotted:hover,.b-link--dotted:focus{color:#646060;border-bottom:2px dotted #646060;text-decoration:none}.b-link--unstyled{color:inherit;text-decoration:none}.b-link--unstyled:active,.b-link--unstyled:hover,.b-link--unstyled:focus{color:inherit;text-decoration:none}.b-link--docs{transition:color .3s ease;color:#00aeef;text-decoration:none;font-weight:700}.b-link--docs:active,.b-link--docs:hover,.b-link--docs:focus{text-decoration:underline}a.anchor{scroll-margin-top:128px}.b-btn{font-family:Arial,sans-serif;font-size:1rem;color:#252525;color:inherit;text-decoration:none}.b-btn:active,.b-btn:hover,.b-btn:focus{color:inherit;text-decoration:none}.b-btn{display:inline-flex;justify-content:center;align-items:center;padding:.8333333333rem 1.728rem;border:1px solid #BAC82A;background-color:#cbdb2a;cursor:pointer}.b-btn:active,.b-btn:hover,.b-btn:focus{background-color:#daed1a;border:1px solid #CBDB2A;text-decoration:none}.b-btn.b-btn--disabled,.b-btn:disabled{background-color:#f8f8f8;border:1px solid #EAEAEA;color:#646060;cursor:not-allowed;pointer-events:all!important}@media(min-width:769px){.b-btn--large{padding:1.2rem 2.48832rem}}.b-btn--secondary{background-color:#fff}.b-btn--secondary:active,.b-btn--secondary:hover,.b-btn--secondary:focus{background-color:#fff}.b-btn--block{width:100%}.b-btn__icon{margin-right:1rem}.b-btn__icon--after{margin-right:0;margin-left:1rem}input[type=text]{-webkit-appearance:none;-moz-appearance:none;appearance:none}.b-label{color:#646060;font-size:.8333333333rem;margin-bottom:.4822530864rem;width:100%}.b-input-group{display:flex;flex-direction:column}@media(min-width:769px){.b-input-group{flex-direction:row}}.b-input{font-family:Arial,sans-serif;font-size:1rem;color:#252525;border:solid 1px #646060;padding:.8333333333rem;line-height:1.2}.b-form-group--with-icon .b-input{padding-left:46px}.b-input::placeholder{color:#bfbfbf;opacity:1}.b-input,.b-input--block{width:100%}.b-form-group{display:flex;flex-direction:column}.b-form-group .b-btn{margin-top:1rem}@media(min-width:769px){.b-form-group .b-btn{margin-top:0;margin-left:.8333333333rem}}.b-form-group--with-icon{position:relative}.b-form-group--with-icon .b-icon{position:absolute;width:20px;height:20px;top:14px;left:10px}.c-eg-page{max-width:800px;margin:0 auto;padding:1.44rem 1.2rem}.c-eg-page__title{font-family:Arial,sans-serif;font-weight:700;font-size:1.2rem;color:#252525;margin:0 0 1.44rem}.c-eg-page__heading{font-family:Arial,sans-serif;font-weight:700;font-size:1rem;color:#252525;margin:1.728rem 0 1.2rem}.c-eg-page__lead{margin-bottom:1.44rem}.c-eg-section{margin-bottom:1.728rem}.c-eg-alert{font-family:Arial,sans-serif;font-size:1rem;color:#252525;border:1px solid #CC2B27;border-radius:5px;background-color:#fef1f9;padding:.8333333333rem 1rem;margin:1.2rem 0}.c-eg-alert a{color:#cc2b27;font-weight:700}.c-eg-table{font-family:Arial,sans-serif;font-size:1rem;color:#252525;width:100%;max-width:800px;font-size:.8333333333rem;background-color:#fff;border-collapse:collapse;margin-bottom:1.2rem}.c-eg-table__head .c-eg-table__cell{font-weight:700;border-bottom:3px solid #EAEAEA}.c-eg-table__cell{padding:.5787037037rem .6944444444rem;text-align:left;vertical-align:top;border-bottom:1px solid #EAEAEA}.c-eg-table__cell--key{font-weight:700;white-space:nowrap}.c-eg-table__cell ul{margin:0;padding-left:1rem}.c-eg-table__row--used{background-color:#f9fbe9}.c-eg-table__row--present,.c-eg-table__row--alt{background-color:#f8f8f8}.c-eg-table__action{padding:.4822530864rem .6944444444rem;font-size:.6944444444rem;white-space:nowrap;width:auto}.c-eg-legend{font-size:.6944444444rem;color:#646060;margin-bottom:.6944444444rem}.c-eg-legend__swatch{padding:0 .4822530864rem;border-radius:5px}.c-eg-legend__swatch--used{background-color:#f9fbe9}.c-eg-legend__swatch--present{background-color:#f8f8f8}.c-eg-form{margin-bottom:1.44rem}.c-eg-form__row{display:flex;flex-direction:column}@media(min-width:769px){.c-eg-form__row{flex-direction:row;align-items:flex-end}}.c-eg-form .b-btn{margin-top:.8333333333rem}@media(min-width:769px){.c-eg-form .b-btn{margin-top:0;margin-left:.8333333333rem}}.c-eg-map{margin:1.44rem 0}.c-eg-map__title{font-family:Arial,sans-serif;font-weight:700;font-size:1rem;color:#252525;margin:0 0 1.2rem}.c-eg-map__canvas{height:400px;width:100%;border:1px solid #EAEAEA;border-radius:5px}.c-eg-columns{display:grid;grid-template-columns:1fr;gap:1.44rem}@media(min-width:993px){.c-eg-columns{grid-template-columns:1fr 1fr}}.c-eg-details{margin:1.2rem 0 1.44rem;border:1px solid #EAEAEA;border-radius:5px;padding:0 1rem}.c-eg-details>summary{cursor:pointer;padding:.6944444444rem 0;font-weight:700}.c-eg-button-row{margin:1.2rem 0 1.44rem}.c-eg-message{display:flex;flex-direction:column;align-items:flex-start;gap:.8333333333rem;margin-top:2.0736rem;padding:1rem 1.2rem;background:#f8f8f8;border:1px solid #EAEAEA;border-radius:5px}@media(min-width:769px){.c-eg-message{flex-direction:row;align-items:center;justify-content:space-between;gap:1.2rem}}.c-eg-message__text{margin:0}.c-eg-message__cta{flex-shrink:0}.c-eg-status{font-family:Arial,sans-serif;font-size:1rem;color:#252525;font-weight:700}.c-eg-status--pass{color:#9ba90d}.c-eg-status--fail{color:#cc2b27}.c-eg-status--part{color:#f5841f}.c-eg-status--none{color:#646060;font-weight:400}.c-eg-value{font-family:Consolas,monospace;font-size:.8333333333rem;color:#646060;word-break:break-all} diff --git a/fiftyone_pipeline_did/examples/creator_context_web/page.html b/fiftyone_pipeline_did/examples/creator_context_web/page.html new file mode 100644 index 0000000..a614889 --- /dev/null +++ b/fiftyone_pipeline_did/examples/creator_context_web/page.html @@ -0,0 +1,453 @@ + + + + + + +51Did creator context demo + + + + +
+

🔐 51Did creator context demo

+

This page creates a 51Did in your browser, + verifies it from your browser, and redeems the encrypted creator + context result on its own server, which is the only place the licence + key lives.

+ +

Result

+ + + + + + + + + + + + + + + + + + + + + +
StepOutcome
Identifierworking…
Signaturewaiting…
Creator + contextwaiting…
+ + + + + + + + + + +
+ + + + diff --git a/fiftyone_pipeline_did/examples/creator_context_web/server.py b/fiftyone_pipeline_did/examples/creator_context_web/server.py new file mode 100644 index 0000000..9452a23 --- /dev/null +++ b/fiftyone_pipeline_did/examples/creator_context_web/server.py @@ -0,0 +1,285 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""51Did creator context demo server. + +Serves ``page.html`` with a fresh challenge per load, and redeems the +encrypted result server side with the ``DidClient`` from this package, +adding the licence key the browser never sees. The page runs the 51Did +flow the way production does. + +1. Create a 51Did by calling the ``json`` endpoint, which issues an + identifier for the calling connection. The browser makes this call, + so the identifier is created for the browser's own connection. +2. Verify it with ``verify-full``, which returns both the signature + outcome and the creator context verdict only as an encrypted + ``result`` that the caller cannot read or forge. (A deployment + holding no context secret answers in the open instead.) The browser + makes this call too, so the cloud observes the browser's live connection, + then the page hands the encrypted result to this server. +3. Parse the 51Did, check its signature offline against the published + public keys, then redeem the encrypted result with ``redeem``, + presenting the 51Did, the encrypted result and the account's licence + key, and receive the true creator context verdict, when the + verification happened (``verifiedAt``) and how long ago that was + (``secondsSinceVerified``). This server makes that call, as the only + party holding the licence key. + +A fresh challenge is issued per page load and bound through both steps +by the cloud. A production server would also remember the value it +issued and reject a redemption carrying any other, which this demo +keeps out of scope. + +What a run costs. Every call to the cloud is one use against the +subscription behind the resource key. A browser-based context check +makes two, verify-full from the page and redeem from this server, so +two uses every time. The creation call is a further use. The public +key list the offline check needs is fetched once and cached for a day. + +Environment variables. ``_51DEGREES_RESOURCE_KEY`` (or the older +``RESOURCE_KEY``) is required. ``_51DEGREES_LICENSE_KEY`` (or +``LICENSE_KEY``) is optional, and the comment in ``run`` says why. +``FOD_CLOUD_API_URL`` is the cloud API base including the ``/api/v4/`` +segment, defaulting to ``https://cloud.51degrees.com/api/v4/``, and is +the same variable the cloud request engine and the ``DidClient`` honour. +``PORT`` is the port to listen on, defaulting to 5100. + +Standard library plus this package. Run ``python server.py`` then open +``http://localhost:5100/``. +""" + +import json +import os +import secrets +import sys +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +# The package from this repository rather than a published one, so the +# branch is what runs. When the package is installed the import succeeds +# directly, and otherwise the package source beside this example is used, +# which is how a checkout runs the demo without an install step. The OWID +# library the package builds on must be installed either way (see the +# package readme). +try: + from fiftyone_pipeline_did import ( + DidClient, DidClientError, DidNotSupportedError, FodId) +except ImportError: + sys.path.insert(0, str(HERE.parents[1] / "src")) + from fiftyone_pipeline_did import ( + DidClient, DidClientError, DidNotSupportedError, FodId) + +DEFAULT_API = "https://cloud.51degrees.com/api/v4/" + + +def env(*names): + """The first of the named environment variables that is set and not + empty, so the aligned name is tried before the older one.""" + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + +RESOURCE = env("_51DEGREES_RESOURCE_KEY", "RESOURCE_KEY") +LICENCE = env("_51DEGREES_LICENSE_KEY", "LICENSE_KEY") or "" +# The cloud API base, normalised to end in exactly one slash so that +# every URL is the base followed by its path. The page receives the same +# value through its __API__ placeholder and builds its two cloud calls +# from it, and the DidClient treats the same variable the same way. A +# host other than cloud.51degrees.com would be used to (a) use an on +# premise web server, or (b) use a privately hosted version of the +# 51Degrees cloud for performance reasons. That is the private hosting +# option of the cloud service, and both run the same service, so this +# demo works unchanged against either. +API = (env("FOD_CLOUD_API_URL") or DEFAULT_API).rstrip("/") + "/" +PORT = int(os.environ.get("PORT", "5100")) + + +def page_html(): + # Both files are read PER REQUEST, not once at start-up. A demo left + # running while its page is edited would otherwise keep serving the + # version it started with, which looks exactly like an edit that did + # not work. The cost is one small file read per request, which is + # nothing at demo scale. + return (HERE / "page.html").read_text(encoding="utf-8") + + +def css_bytes(): + # The design system stylesheet, vendored beside this server exactly + # as the other 51Degrees web examples vendor it. Its source of truth + # is pattern-library/source/sass in the 51Degrees/documentation + # repository. + return (HERE / "examples-main.min.css").read_bytes() + + +class Demo(BaseHTTPRequestHandler): + """The demo's three routes. ``client`` is the one ``DidClient`` the + whole server shares, set by ``run`` from the environment, or by a test + with a stand-in transport.""" + + client = None + + def do_GET(self): + url = urllib.parse.urlparse(self.path) + if url.path == "/redeem": + self.redeem(urllib.parse.parse_qs(url.query)) + elif url.path == "/examples-main.min.css": + body = css_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/css") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif url.path == "/": + self.page() + else: + self.send_error(404) + + def page(self): + body = (page_html() + .replace("__RESOURCE__", RESOURCE) + .replace("__CHALLENGE__", secrets.token_hex(16)) + .replace("__API__", API)).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def redeem(self, query): + """The server-side step. The licence key is inside the client and + is added here and only here, so the browser never sees it. + + Answers the page with the cloud's status and a body in the cloud's + own shape (signature, context, factors when present, verifiedAt, + secondsSinceVerified) built from the typed result, plus + serverSignature, which is this server's own offline check of the + identifier's signature against the published public keys. The page + ignores fields it does not know, so page.html is the same for every + language. + """ + client = self.client + if client is None: + self.send_json(500, {"error": "The server has no DidClient. " + "Start it with run()."}) + return + # The identifier arrives in the URL-safe alphabet from the page, + # which from_base64 accepts alongside the standard one. The + # parameter is named 51did, because the value is a 51Did and OWID + # is only the envelope format it travels in. + try: + fod_id = FodId.from_base64(query.get("51did", [""])[0]) + except Exception as error: + # The caller's own identifier, so naming the fault costs + # nothing, which is the same 400 with an errors list the cloud + # gives. + self.send_json(400, { + "errors": ["51did is not a valid 51Did: {0}".format(error)]}) + return + try: + # The signature checked here, offline, before the cloud is + # asked to redeem anything, so a forged envelope is named by + # this server rather than only by the cloud. + signature_valid = client.verify_signature(fod_id) + redeemed = client.redeem( + fod_id, + query.get("result", [""])[0], + query.get("challenge", [""])[0]) + body = redeemed.to_dict() + body["serverSignature"] = \ + "verified" if signature_valid else "invalid" + self.send_json(redeemed.status_code, body) + except DidNotSupportedError as error: + # A host without the creator context answers 404 with a text + # body, which the page reports as not supported by this host. + self.send_text(404, error.body or str(error)) + except DidClientError as error: + if error.status_code is None: + self.send_json(502, {"error": str(error)}) + return + # Relayed as the cloud said it, status and body, so a failure + # reads on the page as what the cloud said. + body = error.body or str(error) + if _is_json(body): + self.send_bytes(error.status_code, "application/json", + body.encode("utf-8")) + else: + self.send_text(error.status_code, body) + except OSError as error: + # An unreachable cloud must answer the page, not crash the + # demo server. urllib's URLError is an OSError. + self.send_json(502, {"error": "redeem failed: {0}".format( + getattr(error, "reason", error))}) + + def send_json(self, status, body): + self.send_bytes(status, "application/json", + json.dumps(body).encode("utf-8")) + + def send_text(self, status, text): + self.send_bytes(status, "text/plain; charset=utf-8", + text.encode("utf-8")) + + def send_bytes(self, status, content_type, body): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def _is_json(text): + try: + json.loads(text) + return True + except ValueError: + return False + + +def run(): + if not RESOURCE: + sys.exit("Set _51DEGREES_RESOURCE_KEY (or RESOURCE_KEY) to the " + "resource key of the page.") + if not LICENCE: + # Only an account that holds licence keys needs one to redeem, + # because the licence key is what keeps redemption to the acting + # party's own servers. An account holding none has nothing to + # check against, so the demo runs without it. Saying so here + # means an account that DOES hold licence keys, run without one, + # is diagnosed at start-up rather than by an unreadable verdict + # three steps later that looks like a cryptographic failure. + print("No _51DEGREES_LICENSE_KEY set. Redemption will work where " + "the account holds no licence keys, and will report the " + "context unreadable where it holds some.") + # One client for the whole server. It holds the resource key, the + # licence key and the endpoint, and caches the public key list. + Demo.client = DidClient(RESOURCE, LICENCE or None, API) + print(f"51Did demo on http://localhost:{PORT}/") + HTTPServer(("", PORT), Demo).serve_forever() + + +if __name__ == "__main__": + run() diff --git a/fiftyone_pipeline_did/readme.md b/fiftyone_pipeline_did/readme.md index d6afff3..e4d9fed 100644 --- a/fiftyone_pipeline_did/readme.md +++ b/fiftyone_pipeline_did/readme.md @@ -1,7 +1,8 @@ # fiftyone_pipeline_did -Strongly typed Python reader for the 51Did (51Degrees Identifier) returned by -the 51Degrees Cloud service. Mirrors the .NET `FiftyOne.Did` package. +Strongly typed Python reader and cloud client for the 51Did (51Degrees +Identifier) returned by the 51Degrees Cloud service. Mirrors the .NET +`FiftyOne.Did` package. ## Terminology @@ -46,7 +47,7 @@ switch to upstream once published). `Owid` is composed, not subclassed: ```python from fiftyone_pipeline_did import FodId, IdType -fod_id = FodId.from_base64(base64_from_cloud_service) +fod_id = FodId.from_base64(base64_from_cloud_service) # either alphabet flags = fod_id.flags type_ = fod_id.type # IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL @@ -55,10 +56,19 @@ value = fod_id.hash # SHA-256 or GUID bytes, see type # Delegated OWID-level fields and operations. domain = fod_id.domain +minutes = fod_id.date_minutes # the date field: minutes since 2020-01-01Z verified = fod_id.verify(public_key_pem) -base64 = fod_id.as_base64() +base64 = fod_id.as_base64() # standard alphabet, padded, as the cloud +url_safe = fod_id.as_base64_url() # URL-safe alphabet, no padding, for a link ``` +`from_base64` accepts the standard alphabet the cloud issues and the +URL-safe alphabet a page puts in a link, with or without padding. On an +identifier carrying a creator context the License Id field holds an +encrypted value that only 51Degrees can turn back into a licence +identifier, so `license_id` is the field's raw value and identifies +nothing outside 51Degrees. + ## Comparing two 51Dids ```python @@ -70,6 +80,246 @@ b = FodId.from_base64(idprobglobal_b) same_value = a.hash == b.hash ``` +## Verifying on your server + +`DidClient` handles every manipulation of a 51Did a server needs against +the cloud, so server code never builds a cloud URL or handles a key +itself. One instance serves a whole server, and its key cache is safe to +share across threads. It uses `urllib` and `json` from the standard +library, so this package gains no dependency the pipeline does not +already carry. + +```python +import os +from fiftyone_pipeline_did import DidClient, FodId + +client = DidClient( + os.environ["_51DEGREES_RESOURCE_KEY"], + os.environ.get("_51DEGREES_LICENSE_KEY"), # optional, see below + # endpoint defaults to FOD_CLOUD_API_URL, then the public cloud +) +``` + +| Argument | Meaning | +| --- | --- | +| `resource_key` | Required. The page's resource key, public by nature. It travels in the route of the key and verify requests and in the form body of the redeem request | +| `licence_key` | Optional. A licence key of the same account, server side only. Needed to redeem where the account holds licence keys. Sent only in the body of the redeem request, never in a URL | +| `endpoint` | Optional. The API base including the `/api/v4/` segment. Defaults to the `FOD_CLOUD_API_URL` environment variable, the same variable the cloud request engine honours, then to `https://cloud.51degrees.com/api/v4/`. A value without a trailing slash gains one | +| `transport` | Optional. The HTTP transport, either a callable taking the prepared `urllib.request.Request` and returning `(status, body_bytes)`, or an `urllib.request.OpenerDirector`. Defaults to `urllib.request.urlopen`. Tests inject one | +| `now` | Optional. The clock, returning an aware UTC `datetime`. Tests inject one | + +Every request carries a `User-Agent` naming this package and its version. + +**1. Parse.** The identifier arrives from a page in the URL-safe alphabet +and from the cloud in the standard one. `from_base64` takes either, with +or without padding, and `as_base64_url()` gives the form to put in a URL. + +```python +fod_id = FodId.from_base64(fifty_one_did) +``` + +**2. Verify the signature offline.** The client fetches the published +signing public keys from the cloud once, caches them for a day, and picks +the key in force when the identifier was created, being the entry whose +start is latest on or before the identifier's date (a key stays in force +until the next one starts, and keys are published up to three months +ahead). Near a period boundary the neighbouring key is tried as well. No +earlier key is ever tried. The envelope version must be the one the +cloud signs and the payload at least the base length for its type, and a +longer payload carries a creator context and is accepted, its exact +lengths being for the cloud to judge. + +```python +valid = client.verify_signature(fod_id) # bool +check = client.verify_signature_detailed(fod_id) # SignatureCheck +# check.valid is False and check.reason is SignatureReason.NO_KEY when +# no published key covers the identifier's date +keys = client.public_keys() # [PublicKeyEntry(starts_at, public_key)] +key = client.public_key_for(fod_id) # the entry in force, or None +``` + +**3. Verify the signature through the cloud.** The open `verify` +endpoint, one use against the resource key and no licence key needed. The +identifier is sent under both the `51did` and `owid` query names, so the +call works with hosts that read either parameter. A value the cloud cannot +parse as a 51Did raises +`DidArgumentError` (a `ValueError`) carrying the cloud's message. + +```python +valid = client.verify(fod_id) # bool +``` + +**4. Redeem a sealed creator context result.** The verify-context and +verify-full endpoints are browser calls, because the creator context +describes the browser's own connection, and they return the verdict only +as an encrypted `result` the browser cannot read or forge. The party that +acts on it redeems it on the server, with the licence key, against the +51Did it knows independently. + +```python +redeemed = client.redeem(fod_id, result, challenge) +redeemed.context # ContextResult: VERIFIED, MISMATCH, + # NO_CONTEXT, NOT_CHECKABLE, EXPIRED, + # REPLAYED, UNREADABLE, UNCONFIRMED +redeemed.signature # SignatureResult: VERIFIED, INVALID + # or UNKNOWN +redeemed.factors # only on a mismatch: name to + # FactorResult (VERIFIED or MISMATCH) + # or None where nothing was compared, + # for transport, device, browserip, + # connectionip, asn and browser +redeemed.verified_at # datetime, on the redeemed and expired + # outcomes +redeemed.seconds_since_verified +redeemed.status_code # 200, or 503 for UNCONFIRMED, which may + # be retried +redeemed.raw # the body as received +redeemed.to_dict() # the cloud's own response shape, for + # relaying to a page +``` + +A context string this package does not know maps to `UNREADABLE`, so an +unrecognised outcome is never mistaken for a good one, and `context_raw` +keeps the string as sent. Every cryptographic failure comes back from the +cloud as the one word `unreadable` by design, a missing licence key +included, so the client does not try to tell them apart either. A cloud +that cannot parse the 51Did raises `DidArgumentError` (HTTP 400), a host +that does not offer the creator context raises `DidNotSupportedError` +(HTTP 404), and any other status raises `DidClientError` carrying +`status_code` and `body`. A transport failure raises the `OSError` the +transport raised, which is `urllib.error.URLError` by default. + +## Examples + +The `examples` folder holds an offline example and a web demo that +calls the 51Degrees cloud. + +`fodid_example.py` builds a sample 51Did in process and parses it back +with this package, so it needs no resource key and makes no cloud calls. + +`creator_context_web/` is a small demo web app, `server.py` serving +`page.html`, that runs the 51Did creator context flow the way +production does. The creator context only makes sense from a browser, +because a server verifying its own connection would be checking itself +against itself, so there is no console example. + +1. **Create** a 51Did by calling the `json` endpoint, which issues an + identifier for the calling connection. The browser makes this call. +2. **Verify** it with `verify-full`, which returns both the signature + outcome and the creator context verdict only as an encrypted + `result` that the caller cannot read or forge. (A deployment holding + no context secret answers in the open instead.) The browser makes + this call too, so the cloud observes the browser's live connection, + then the page hands the encrypted result to its own server. +3. **Redeem** the encrypted result with `redeem`, presenting the 51Did, + the encrypted result and the account's licence key, and receive the + true creator context verdict, when the verification happened + (`verifiedAt`) and how long ago that was (`secondsSinceVerified`). + The server makes this call, as the only party holding the licence + key. + +A fresh challenge is issued per page load and bound through both steps +by the cloud. A production server would also remember the value it +issued and reject a redemption carrying any other, which the demo +keeps out of scope. + +### The server-side step to copy into your own server + +The one part that belongs on your server is the redeem call with the +licence key, which is what the `/redeem` handler in `server.py` does +with the `DidClient` from this package. It parses the identifier the +page sent, checks its signature offline against the published public +keys, then redeems the encrypted result with the challenge, adding the +licence key the browser never sees. The essential lines are these. + +```python +from fiftyone_pipeline_did import DidClient, FodId + +# Once, at start-up. RESOURCE, LICENCE and API come from the +# environment variables below. +client = DidClient(RESOURCE, LICENCE or None, API) + +# In the /redeem handler, with 51did, result and challenge from the +# page. The identifier arrives in the URL-safe alphabet, which +# from_base64 accepts alongside the standard one. +fod_id = FodId.from_base64(query.get("51did", [""])[0]) +signature_valid = client.verify_signature(fod_id) +redeemed = client.redeem( + fod_id, query.get("result", [""])[0], query.get("challenge", [""])[0]) +body = redeemed.to_dict() +body["serverSignature"] = "verified" if signature_valid else "invalid" +self.send_json(redeemed.status_code, body) +``` + +The handler answers the page with the cloud's status and a body in the +cloud's own shape (`signature`, `context`, `factors` when present, +`verifiedAt`, `secondsSinceVerified`) built from the typed result, plus +`serverSignature`, the server's own offline check of the identifier's +signature. The page ignores fields it does not know, so `page.html` is +the same for every language. + +A verdict of `nocontext` is a normal outcome and not an error. A +self-hosted container may be configured not to emit the creator +context, so an identifier it issued has nothing to check and redeems +as `nocontext` with no factors, and the page shows it the way it shows +any verdict. A 404 from `verify-full` or `redeem` means the host +answering does not support the creator context at all, which is a +service without the feature rather than a failed check. The client +raises `DidNotSupportedError` for that case, the handler answers 404 +with a text body, and the page shows "not supported by this host". Any +other answer the cloud gives is relayed with its status and body, so +the page reports whatever the service said readably, and an +unreachable cloud answers 502 with `{ "error": ... }`. + +### Environment variables + +| Variable | Meaning | +| --- | --- | +| `_51DEGREES_RESOURCE_KEY` | Required. The page's resource key, public by nature. The older `RESOURCE_KEY` is read when the aligned name is not set | +| `_51DEGREES_LICENSE_KEY` | Optional. A licence key of the same account, held server side only. Only an account that holds licence keys needs one to redeem, so an account holding none runs without it. The older `LICENSE_KEY` is read when the aligned name is not set | +| `FOD_CLOUD_API_URL` | Optional. The cloud API base including the `/api/v4/` segment, defaulting to `https://cloud.51degrees.com/api/v4/`. This is the same variable the cloud request engine honours. A host other than cloud.51degrees.com would be used to (a) use an on premise web server, or (b) use a privately hosted version of the 51Degrees cloud for performance reasons, which is the private hosting option of the cloud service. Both run the same service, so the demo works unchanged against either | +| `PORT` | The port to listen on, defaulting to `5100` | + +### Running + +With the resource key set as above: + +``` +cd fiftyone_pipeline_did/examples/creator_context_web +python server.py +``` + +then open `http://localhost:5100/`. To demonstrate across two devices, +serve on an address both can reach and open the copied link on the +second device. + +### What a run costs + +Every call the demo makes to the cloud is one use against the +subscription behind the resource key. Checking a 51Did from the browser +makes two, verify-full from the page and redeem from the server, so a +browser-based context check is two uses every time. Checking only the +signature with `verify` is one use. + +### The copy-and-paste proof + +Once the 51Did has fully validated, the page shows a **copy-and-paste +section** with a link carrying the same 51Did, and an explanation of +what will happen next. Open that link in a **different browser** and +the same page loads with the same identifier. The signature still +verifies and the identifier unpacks, because it is genuine, but the +creator context does **not** validate, because the context binds the +identifier to the browser and connection it was created on. That +visible failure is the demonstration that matters, a copied or stolen +identifier caught at presentation with nothing stored server side. +Opening the link in the same browser is not the demonstration, since +the same browser presents the same context and may still verify. + +### The stylesheet + +`examples-main.min.css` beside the demo is the design system build and +is refreshed by common-ci's `update-example-assets` step. + ## Non-goals - **No signature verification on construction.** Call `verify(public_key_pem)` diff --git a/fiftyone_pipeline_did/setup.py b/fiftyone_pipeline_did/setup.py index 1116478..f531de7 100644 --- a/fiftyone_pipeline_did/setup.py +++ b/fiftyone_pipeline_did/setup.py @@ -42,7 +42,7 @@ def read(file_name): author="51Degrees Engineering", author_email="engineering@51degrees.com", url="https://51degrees.com/?utm_source=pypi&utm_medium=package&utm_campaign=pipeline-python&utm_content=fiftyone_pipeline_did-setup.py&utm_term=url", - description=("Strongly typed reader for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope and exposes the Flags, License Id and value (Hash) plus the identifier type. Compare values, never envelopes."), + description=("Strongly typed reader and cloud client for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope in either base64 alphabet and exposes the Flags, License Id and value (Hash) plus the identifier type, and verifies a 51Did's signature offline or through the cloud and redeems a sealed creator context result on the server. Compare values, never envelopes."), long_description=read("readme.md"), long_description_content_type='text/markdown', python_requires=">=3.9", diff --git a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py index c68f642..803a5e9 100644 --- a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py +++ b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py @@ -20,16 +20,55 @@ # such notice(s) shall fulfill the requirements of that article. # ********************************************************************* -"""Strongly typed reader for the 51Did (51Degrees Identifier) value. +"""Strongly typed reader and cloud client for the 51Did (51Degrees +Identifier) value. :class:`~fiftyone_pipeline_did.fod_id.FodId` parses a 51Did from its base64 -OWID form, exposes the three payload fields (Flags, License Id and the value -Hash) and the identifier :class:`~fiftyone_pipeline_did.id_type.IdType`, and -delegates OWID-level concerns to the wrapped envelope. Compare 51Dids by their -value (``hash``), never by their envelopes. +OWID form in either alphabet, exposes the three payload fields (Flags, +License Id and the value Hash) and the identifier +:class:`~fiftyone_pipeline_did.id_type.IdType`, and delegates OWID-level +concerns to the wrapped envelope. Compare 51Dids by their value (``hash``), +never by their envelopes. + +:class:`~fiftyone_pipeline_did.did_client.DidClient` handles every +manipulation of a 51Did a server needs against the 51Degrees cloud: the +signing public keys and the key in force when an identifier was created, +offline and cloud signature verification, and redeeming a sealed creator +context result with the licence key into a typed +:class:`~fiftyone_pipeline_did.did_client.RedeemResult`. """ -from .fod_id import FodId +from .did_client import ( + DEFAULT_ENDPOINT, + ContextResult, + DidArgumentError, + DidClient, + DidClientError, + DidNotSupportedError, + FactorResult, + PublicKeyEntry, + RedeemResult, + SignatureCheck, + SignatureReason, + SignatureResult, +) +from .fod_id import DATE_EPOCH, FodId from .id_type import IdType -__all__ = ["FodId", "IdType"] +__all__ = [ + "FodId", + "IdType", + "DATE_EPOCH", + "DidClient", + "RedeemResult", + "PublicKeyEntry", + "SignatureCheck", + "ContextResult", + "SignatureResult", + "FactorResult", + "SignatureReason", + "DidClientError", + "DidArgumentError", + "DidNotSupportedError", + "DEFAULT_ENDPOINT", +] diff --git a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py new file mode 100644 index 0000000..6bcd9bd --- /dev/null +++ b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py @@ -0,0 +1,776 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""The 51Did cloud client. + +:class:`DidClient` handles every manipulation of a 51Did a server needs +against the 51Degrees cloud, so server code never builds a cloud URL or +handles a key itself. It fetches the signing public keys once and caches +them, picks the key in force when an identifier was created, verifies a +signature offline against that key, verifies a signature through the +cloud's verify endpoint, and redeems a sealed creator context result with +the licence key, returning a typed :class:`RedeemResult`. + +Creating a 51Did is not part of this client. Creation is the cloud ``json`` +endpoint through the cloud request engine and pipeline, and a page creates +from the browser because the identifier describes the browser's own +connection. The verify-context and verify-full endpoints are browser calls +for the same reason and are not offered here. + +Standard library only (``urllib`` and ``json``), so this package gains no +dependency the pipeline does not already carry. +""" + +from __future__ import annotations + +import json +import os +import re +import threading +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from owid import Version + +from .fod_id import DATE_EPOCH, FodId +from .id_type import IdType + +#: The public cloud API base, used when neither the ``endpoint`` argument +#: nor the ``FOD_CLOUD_API_URL`` environment variable is set. +DEFAULT_ENDPOINT = "https://cloud.51degrees.com/api/v4/" + +#: The environment variable the cloud request engine reads for the API +#: base, honoured here when no endpoint argument is given. +ENDPOINT_VARIABLE = "FOD_CLOUD_API_URL" + +_BOUNDARY_TOLERANCE = timedelta(minutes=15) + +#: A cached key list older than this is fetched again before use. +KEY_LIST_MAX_AGE = timedelta(days=1) + +#: The only envelope version the cloud signs and verifies. +SUPPORTED_VERSION = Version.VERSION3 + +#: Seconds to wait for the cloud before the default transport gives up. +DEFAULT_TIMEOUT = 30.0 + +# The longest encoded identifier this client will look at. This is a guard +# against obviously malformed input, not a statement about how long a +# 51Did is, because the lengths an identifier can have belong to the +# cloud. The figure is arbitrary and generous on purpose so that nothing +# about the layout of an identifier can be read from it. +_MAXIMUM_ENCODED_LENGTH = 4096 + + +def _package_version() -> str: + """The installed version of this package, for the User-Agent, or + ``unknown`` when it is imported from a checkout that is not + installed.""" + try: + from importlib.metadata import version + return version("fiftyone_pipeline_did") + except Exception: + return "unknown" + + +#: Sent with every request so the cloud can tell which package called. +USER_AGENT = "fiftyone_pipeline_did/" + _package_version() + + +class ContextResult(str, Enum): + """The creator context outcome of a redemption, as the cloud reports it + in the ``context`` field. The values are the cloud's own strings, so a + result can be compared to a member or printed as received.""" + + #: Every factor matched the browser and connection that created it. + VERIFIED = "verified" + #: At least one factor differed. ``factors`` says which. + MISMATCH = "mismatch" + #: The identifier carries no creator context. + NO_CONTEXT = "nocontext" + #: The service holds no secret covering the identifier's date. + NOT_CHECKABLE = "notcheckable" + #: The sealed result was redeemed outside the freshness window. + EXPIRED = "expired" + #: The sealed result had already been redeemed on that instance. + REPLAYED = "replayed" + #: The sealed result could not be read. Every cryptographic failure, a + #: missing licence key included, comes back as this one word by design, + #: and a context string this package does not know is mapped here too. + UNREADABLE = "unreadable" + #: First use could not be confirmed (HTTP 503). The caller may retry. + UNCONFIRMED = "unconfirmed" + + +class SignatureResult(str, Enum): + """The signature outcome of a redemption, as the cloud reports it in the + ``signature`` field of a redeemed result. Absent on every other + outcome.""" + + VERIFIED = "verified" + INVALID = "invalid" + #: The cloud did not report the signature, as on an expired result. + UNKNOWN = "unknown" + + +class FactorResult(str, Enum): + """The outcome of one factor in a mismatch. The cloud reports ``null`` + for a factor that was not compared, which is passed through as + ``None``.""" + + VERIFIED = "verified" + MISMATCH = "mismatch" + + +class SignatureReason(str, Enum): + """The reason a :meth:`DidClient.verify_signature_detailed` answer was + given.""" + + #: A candidate key verified the signature. + VERIFIED = "verified" + #: The envelope version is not the one the cloud signs. + VERSION = "version" + #: The payload is shorter than the base length for its type. + LENGTH = "length" + #: No published key covers the identifier's date. + NO_KEY = "nokey" + #: Every candidate key was tried and none verified the signature. + SIGNATURE = "signature" + + +class DidClientError(Exception): + """An answer from the cloud that was not the one asked for. Carries the + HTTP status and the response body so a caller can relay or log what the + cloud said.""" + + def __init__(self, message: str, status_code: Optional[int] = None, + body: Optional[str] = None) -> None: + super().__init__(message) + #: The HTTP status, where there was one. + self.status_code = status_code + #: The response body, where there was one. + self.body = body + + +class DidArgumentError(DidClientError, ValueError): + """The cloud refused the request because the 51Did sent was not a valid + identifier (HTTP 400 with an ``errors`` list). The message carries the + cloud's own text. Also a :class:`ValueError`, the language's argument + error.""" + + +class DidNotSupportedError(DidClientError): + """The host answering does not offer the creator context (HTTP 404 from + the redeem endpoint). A caller can name this case rather than treat it + as a failed check.""" + + +@dataclass(frozen=True) +class PublicKeyEntry: + """A published signing key and the moment it came into force. A key + stays in force until the next key starts.""" + + #: When the key came, or comes, into force, as an aware UTC datetime. + starts_at: datetime + #: The key in SPKI PEM form. + public_key: str + + +@dataclass(frozen=True) +class SignatureCheck: + """The detailed answer to an offline signature check.""" + + #: Whether a candidate key verified the signature. + valid: bool + #: Why the answer was given. + reason: SignatureReason + + +#: The type of a factor value in :attr:`RedeemResult.factors`. +FactorValue = Optional[FactorResult] + +#: The shape of an injected transport: a callable taking the prepared +#: :class:`urllib.request.Request` and returning the HTTP status and the +#: response body, whatever the status. An object with an ``open`` method +#: (an :class:`urllib.request.OpenerDirector`) is accepted as well. +Transport = Callable[[urllib.request.Request], Tuple[int, bytes]] + +_ISO_8601 = re.compile( + r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?" + r"(Z|[+-]\d{2}:?\d{2})?$") + + +def parse_iso8601(text: str) -> datetime: + """Parses an ISO 8601 date and time as the cloud writes one, with any + number of fractional second digits and ``Z`` or an offset, into an + aware UTC datetime. Raises :class:`ValueError` for anything else. + + Written here rather than through :meth:`datetime.fromisoformat`, + because on Python 3.9 and 3.10 that method takes neither ``Z`` nor the + seven fractional digits the cloud emits. + """ + match = _ISO_8601.match(text.strip()) + if match is None: + raise ValueError("not an ISO 8601 date and time: {0!r}".format(text)) + year, month, day, hour, minute, second, fraction, zone = match.groups() + microsecond = int((fraction or "0")[:6].ljust(6, "0")) + value = datetime(int(year), int(month), int(day), int(hour), + int(minute), int(second), microsecond, + tzinfo=timezone.utc) + if zone and zone != "Z": + sign = 1 if zone[0] == "+" else -1 + digits = zone[1:].replace(":", "") + offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:])) + value = value - sign * offset + return value + + +def _try_parse_json(text: str) -> Any: + """Parses JSON without raising, answering ``None`` for text that is not + JSON.""" + try: + return json.loads(text) + except ValueError: + return None + + +class RedeemResult: + """The typed answer to a redemption. Built from the cloud's JSON body, + with the raw status and body kept for logging.""" + + def __init__(self, status_code: int, raw: str, + parsed: Dict[str, Any]) -> None: + #: The HTTP status the cloud answered with, 200 or 503. + self.status_code = status_code + #: The response body exactly as received. + self.raw = raw + context = parsed.get("context") + context = context if isinstance(context, str) else "" + #: The ``context`` string exactly as the cloud sent it, kept so an + #: outcome this package does not know is still visible. + self.context_raw = context + #: One of :class:`ContextResult`. A string this package does not + #: know maps to :attr:`ContextResult.UNREADABLE`, so an + #: unrecognised outcome is never mistaken for a good one. + self.context = _context_of(context) + signature = parsed.get("signature") + #: One of :class:`SignatureResult`. + self.signature = ( + SignatureResult.VERIFIED if signature == "verified" + else SignatureResult.INVALID if signature == "invalid" + else SignatureResult.UNKNOWN) + factors = parsed.get("factors") + #: Factor name to :class:`FactorResult` (or ``None`` where nothing + #: was compared), present only when the cloud sent ``factors``, + #: which is the mismatch outcome. The names are ``transport``, + #: ``device``, ``browserip``, ``connectionip``, ``asn`` and + #: ``browser``. + self.factors: Optional[Dict[str, FactorValue]] = ( + {str(name): _factor_of(value) for name, value in factors.items()} + if isinstance(factors, dict) else None) + verified_at = parsed.get("verifiedAt") + #: When the verify endpoint checked the context and sealed the + #: result, present on the redeemed and expired outcomes. + self.verified_at: Optional[datetime] = None + if isinstance(verified_at, str): + try: + self.verified_at = parse_iso8601(verified_at) + except ValueError: + self.verified_at = None + seconds = parsed.get("secondsSinceVerified") + #: Whole seconds between the sealing and this redemption by the + #: cloud's clock, present on the redeemed and expired outcomes. + self.seconds_since_verified: Optional[int] = ( + int(seconds) + if isinstance(seconds, (int, float)) + and not isinstance(seconds, bool) else None) + + @classmethod + def from_response(cls, status_code: int, raw: str) -> "RedeemResult": + """Builds a result from a redeem response body, raising + :class:`DidClientError` when the body is not a JSON object.""" + parsed = _try_parse_json(raw) + if not isinstance(parsed, dict): + raise DidClientError( + "Redeem answered HTTP {0} with a body that is not a JSON " + "object: {1}".format(status_code, raw), status_code, raw) + return cls(status_code, raw, parsed) + + def to_dict(self) -> Dict[str, Any]: + """The result in the cloud's own response shape (``signature``, + ``context``, ``factors`` when present, ``verifiedAt``, + ``secondsSinceVerified``), so a server can answer a page with it + directly. ``signature`` is left out when the cloud did not report + it, as the cloud leaves it out.""" + body: Dict[str, Any] = {} + if self.signature is not SignatureResult.UNKNOWN: + body["signature"] = self.signature.value + body["context"] = self.context.value + if self.factors is not None: + body["factors"] = { + name: (value.value if value is not None else None) + for name, value in self.factors.items()} + if self.verified_at is not None: + # ISO 8601 UTC to the second, as the cloud writes it. + body["verifiedAt"] = self.verified_at.astimezone( + timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + if self.seconds_since_verified is not None: + body["secondsSinceVerified"] = self.seconds_since_verified + return body + + def __repr__(self) -> str: + return "RedeemResult(status_code={0}, context={1!r})".format( + self.status_code, self.context_raw) + + +def _context_of(text: str) -> ContextResult: + try: + return ContextResult(text) + except ValueError: + return ContextResult.UNREADABLE + + +def _factor_of(value: Any) -> FactorValue: + if value == "verified": + return FactorResult.VERIFIED + if value == "mismatch": + return FactorResult.MISMATCH + return None + + +class DidClient: + """Everything a server does with a 51Did against the 51Degrees cloud. + + The public key list is cached per instance with the time it was + fetched, behind a lock, so one instance can serve a whole server across + threads. + + :param resource_key: the page's resource key. Required. Public by + nature, it travels in the route of the key and verify requests and + in the form body of the redeem request. + :param licence_key: a licence key of the same account. Server side + only. Needed to redeem where the account holds licence keys, and + sent only in the body of the redeem request, never in a URL. + :param endpoint: the API base including the ``/api/v4/`` segment. + Defaults to the ``FOD_CLOUD_API_URL`` environment variable, then to + the public cloud. A value without a trailing slash gains one. + :param transport: the HTTP transport, either a callable taking the + prepared :class:`urllib.request.Request` and returning + ``(status, body_bytes)``, or an + :class:`urllib.request.OpenerDirector`. Defaults to + :func:`urllib.request.urlopen`. Tests inject one. + :param now: the clock, returning an aware UTC datetime. Defaults to + :meth:`datetime.now` in UTC. Tests inject one. + :param timeout: seconds the default transport waits for the cloud. + """ + + def __init__(self, resource_key: str, licence_key: Optional[str] = None, + endpoint: Optional[str] = None, + transport: Optional[Any] = None, + now: Optional[Callable[[], datetime]] = None, + timeout: float = DEFAULT_TIMEOUT) -> None: + if not isinstance(resource_key, str) or resource_key == "": + raise ValueError("resource_key is required") + self._resource_key = resource_key + self._licence_key = licence_key if licence_key else None + base = endpoint or os.environ.get(ENDPOINT_VARIABLE) or \ + DEFAULT_ENDPOINT + # Normalised to end in exactly one slash so every URL is the base + # plus a relative path, as the cloud request engine treats the same + # value. + self._endpoint = base.rstrip("/") + "/" + self._transport = transport + self._timeout = timeout + self._now = now or (lambda: datetime.now(timezone.utc)) + self._lock = threading.Lock() + self._keys: Optional[List[PublicKeyEntry]] = None + self._fetched_at: Optional[datetime] = None + self._fetch_count = 0 + + @property + def endpoint(self) -> str: + """The API base every request is built on.""" + return self._endpoint + + @property + def resource_key(self) -> str: + """The resource key the requests carry.""" + return self._resource_key + + # ----- Public keys and key selection ----- + + def public_keys(self) -> List[PublicKeyEntry]: + """The published signing keys, oldest first, fetched on first use + and then served from the cache until the list is a day old. Keys are + published up to three months ahead of their start, so the list + holds entries that have not started yet.""" + with self._lock: + return list(self._public_keys_locked()) + + def public_key_for(self, fod_id: Union[FodId, str]) \ + -> Optional[PublicKeyEntry]: + """The key in force when the identifier was created, being the + entry whose start is latest on or before the identifier's date. The + list is fetched again, once, before answering when no entry covers + the date, when the date is later than the newest start held, or + when the list is more than a day old. Answers ``None`` when the + date precedes every published key.""" + identifier = _as_fod_id(fod_id) + date = _date_of(identifier) + return _in_force_at(self._keys_for(date), date) + + # ----- Offline signature verification ----- + + def verify_signature(self, fod_id: Union[FodId, str]) -> bool: + """Verifies the identifier's signature offline against the + published keys, as the cloud's own verify endpoint does. The + envelope version must be the one the cloud signs, the payload must + be at least the base length for its type (a longer payload carries + a creator context and is accepted), and the signature must verify + against the key in force at the identifier's date or, near a + period boundary, the neighbouring key. No earlier key is ever + tried.""" + return self.verify_signature_detailed(fod_id).valid + + def verify_signature_detailed(self, fod_id: Union[FodId, str]) \ + -> SignatureCheck: + """As :meth:`verify_signature`, with the reason alongside the + answer, so a caller can tell an identifier no key covers from one + whose signature failed.""" + identifier = _as_fod_id(fod_id) + if identifier.version != SUPPORTED_VERSION: + return SignatureCheck(False, SignatureReason.VERSION) + if not _payload_length_valid(identifier): + return SignatureCheck(False, SignatureReason.LENGTH) + date = _date_of(identifier) + candidates = _candidates_for_date(self._keys_for(date), date) + if not candidates: + return SignatureCheck(False, SignatureReason.NO_KEY) + for key in candidates: + if identifier.verify(key.public_key): + return SignatureCheck(True, SignatureReason.VERIFIED) + return SignatureCheck(False, SignatureReason.SIGNATURE) + + # ----- Cloud signature verification ----- + + def verify(self, fod_id: Union[FodId, str]) -> bool: + """Verifies the identifier's signature through the cloud's verify + endpoint, the open endpoint that needs no licence key. One use + against the resource key. + + Raises :class:`DidArgumentError` (also a :class:`ValueError`) when + the cloud could not parse the value as a 51Did, with the cloud's + message, and :class:`DidClientError` on any answer other than + valid or invalid. Text far longer than any identifier raises + :class:`ValueError` before transport. A transport failure raises + the :class:`OSError` the transport raised + (:class:`urllib.error.URLError` by default).""" + text = _identifier_text(fod_id) + # The identifier travels under both names so the request works with + # hosts that read either parameter. Hosts that recognise both prefer + # 51did and keep owid as a compatibility alias. + encoded = urllib.parse.quote(text, safe="") + url = "{0}id/verify/{1}?51did={2}&owid={2}".format( + self._endpoint, urllib.parse.quote(self._resource_key, safe=""), + encoded) + status, body = self._send(urllib.request.Request( + url, headers={"User-Agent": USER_AGENT}, method="GET")) + parsed = _try_parse_json(body) + if isinstance(parsed, dict): + valid = parsed.get("valid") + if isinstance(valid, bool): + return valid + errors = parsed.get("errors") + if status == 400 and isinstance(errors, list): + raise DidArgumentError( + " ".join(str(e) for e in errors), status, body) + raise DidClientError( + "Verify answered HTTP {0}: {1}".format(status, body), status, + body) + + # ----- Redeem ----- + + def redeem(self, fod_id: Union[FodId, str], result: str, + challenge: Optional[str] = None) -> RedeemResult: + """Redeems a sealed creator context result against the identifier, + on the server, with the licence key. + + The resource key, the 51Did, the sealed result, the challenge and + the licence key all travel in the body of a POST to ``id/redeem``, + so none of them reaches an access log. (The redeem endpoint takes + the resource key in the form on a POST, where the key and verify + endpoints take it in the route on a GET.) One use against the + resource key, the second of the two a browser context check costs. + + A 200 and a 503 both produce a result, the 503 being the + ``unconfirmed`` outcome the caller may retry. Every cryptographic + failure comes back as the one word ``unreadable`` by design, so the + client does not try to tell them apart either. + + :param fod_id: the identifier the caller knows independently, or + its base64 in either alphabet. + :param result: the sealed result exactly as the verify endpoint + returned it to the page. + :param challenge: the single-use challenge given to the verify + endpoint, where one was. + :raises DidArgumentError: when the cloud could not parse the value + as a 51Did (HTTP 400), with the cloud's message. + :raises ValueError: before transport when the text is far longer + than any identifier. + :raises DidNotSupportedError: when the host does not offer the + creator context (HTTP 404). + :raises DidClientError: on any other status. + :raises OSError: when the transport failed to reach the cloud + (:class:`urllib.error.URLError` by default). + """ + text = _identifier_text(fod_id) + form = [ + ("resource", self._resource_key), + ("51did", text), + ("result", result if isinstance(result, str) else ""), + ("challenge", challenge if isinstance(challenge, str) else ""), + ] + if self._licence_key is not None: + form.append(("license", self._licence_key)) + url = self._endpoint + "id/redeem" + status, body = self._send(urllib.request.Request( + url, + data=urllib.parse.urlencode(form).encode("ascii"), + headers={ + "User-Agent": USER_AGENT, + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST")) + if status in (200, 503): + return RedeemResult.from_response(status, body) + if status == 400: + parsed = _try_parse_json(body) + errors = parsed.get("errors") if isinstance(parsed, dict) \ + else None + message = " ".join(str(e) for e in errors) \ + if isinstance(errors, list) else body + raise DidArgumentError(message, status, body) + if status == 404: + raise DidNotSupportedError( + "The host does not offer the creator context: " + body, + status, body) + raise DidClientError( + "Redeem answered HTTP {0}: {1}".format(status, body), status, + body) + + # ----- Internals ----- + + def _send(self, request: urllib.request.Request) -> Tuple[int, str]: + """Sends the request through the transport and answers the status + and the body as text, whatever the status. A non-2xx answer is an + answer, not an exception, so each caller can read what the cloud + said. Only a failure to reach the cloud raises, as the + :class:`OSError` the transport raised.""" + transport = self._transport + if transport is None: + status, body = _urlopen( + lambda: urllib.request.urlopen(request, + timeout=self._timeout)) + elif hasattr(transport, "open"): + status, body = _urlopen( + lambda: transport.open(request, timeout=self._timeout)) + else: + status, body = transport(request) + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + return int(status), body + + def _public_keys_locked(self) -> List[PublicKeyEntry]: + if self._keys is None or self._stale(): + return self._refresh_locked() + return self._keys + + def _keys_for(self, date: datetime) -> List[PublicKeyEntry]: + """The key list to select from for the given date, fetched again + once where the rule in :meth:`public_key_for` calls for it and the + list was not just fetched.""" + with self._lock: + fetched_before = self._fetch_count + keys = self._public_keys_locked() + if self._fetch_count == fetched_before \ + and self._needs_refetch(keys, date): + keys = self._refresh_locked() + return keys + + def _needs_refetch(self, keys: List[PublicKeyEntry], + date: datetime) -> bool: + if _in_force_at(keys, date) is None: + return True + if keys and date > keys[-1].starts_at: + return True + return self._stale() + + def _stale(self) -> bool: + return self._fetched_at is None \ + or self._now() - self._fetched_at > KEY_LIST_MAX_AGE + + def _refresh_locked(self) -> List[PublicKeyEntry]: + keys = self._fetch_keys() + self._keys = keys + self._fetched_at = self._now() + self._fetch_count += 1 + return keys + + def _fetch_keys(self) -> List[PublicKeyEntry]: + """GET ``id/key/{resource}`` and read each entry's start and public + key. ``startsAt`` is read where present and ``created`` otherwise. + Both are supported start fields in key-list responses. ``weekStart`` + is ignored.""" + url = "{0}id/key/{1}".format( + self._endpoint, urllib.parse.quote(self._resource_key, safe="")) + status, body = self._send(urllib.request.Request( + url, headers={"User-Agent": USER_AGENT}, method="GET")) + if status != 200: + raise DidClientError( + "Public keys answered HTTP {0}: {1}".format(status, body), + status, body) + parsed = _try_parse_json(body) + if not isinstance(parsed, list): + raise DidClientError( + "Public keys answered with a body that is not a JSON " + "array: " + body, status, body) + keys = [] + for entry in parsed: + start = None + public_key = None + if isinstance(entry, dict): + start = entry.get("startsAt") or entry.get("created") + public_key = entry.get("publicKey") + starts_at = None + if isinstance(start, str): + try: + starts_at = parse_iso8601(start) + except ValueError: + starts_at = None + if starts_at is None or not isinstance(public_key, str): + raise DidClientError( + "Public keys entry lacks a start or a publicKey: " + + json.dumps(entry), status, body) + keys.append(PublicKeyEntry(starts_at, public_key)) + keys.sort(key=lambda key: key.starts_at) + return keys + + +def _urlopen(open_call: Callable[[], Any]) -> Tuple[int, bytes]: + """Runs a urllib open and answers the status and body whatever the + status, since urllib raises for anything outside 2xx and the error is + itself the response. A failure to reach the host propagates as the + :class:`urllib.error.URLError` (an :class:`OSError`) it raised.""" + try: + with open_call() as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + body = error.read() + error.close() + return error.code, body + + +def _as_fod_id(value: Union[FodId, str]) -> FodId: + """The identifier as a FodId, parsing a base64 string where one was + given.""" + if isinstance(value, FodId): + return value + if isinstance(value, str): + _ensure_encoded_size(value) + return FodId.from_base64(value) + raise TypeError("fod_id must be a FodId or a base64 string") + + +def _identifier_text(value: Union[FodId, str]) -> str: + """The text sent to the cloud for an identifier. A parsed identifier + goes in the URL-safe alphabet, which needs no further encoding, and a + string goes as given so the cloud can report its own parse error.""" + if isinstance(value, FodId): + return value.as_base64_url() + if isinstance(value, str) and value != "": + _ensure_encoded_size(value) + return value + raise TypeError("fod_id must be a FodId or a non-empty base64 string") + + +def _ensure_encoded_size(value: str) -> None: + """Refuses obviously malformed text before it is parsed, a key is + fetched or the cloud is called. Surrounding whitespace is stripped + before measuring, as the reader strips it before decoding, so the two + measure the same characters.""" + if len(value.strip()) > _MAXIMUM_ENCODED_LENGTH: + raise ValueError( + "The identifier is far longer than any 51Did the cloud " + "issues.") + + +def _date_of(fod_id: FodId) -> datetime: + """The identifier's creation moment, from the minutes the envelope + carries, as an aware UTC datetime.""" + return DATE_EPOCH + timedelta(minutes=fod_id.date_minutes) + + +def _payload_length_valid(fod_id: FodId) -> bool: + """Whether the payload is at least the base length for its type, being + five header bytes plus a 32 byte match key, or 16 for a Random + identifier. Anything beyond the base is a creator context section, + whose exact lengths belong to the cloud, so any longer payload is + accepted here.""" + value_length = FodId.GUID_LENGTH if fod_id.type is IdType.RANDOM \ + else FodId.HASH_LENGTH + return len(fod_id.payload) >= FodId.HEADER_LENGTH + value_length + + +def _in_force_at(keys: List[PublicKeyEntry], + at: datetime) -> Optional[PublicKeyEntry]: + """The entry in force at the moment, being the newest whose start has + passed, or ``None`` when the moment precedes every entry.""" + best = None + for key in keys: + if key.starts_at > at: + continue + if best is None or key.starts_at > best.starts_at: + best = key + return best + + +def _candidates_for_date(keys: List[PublicKeyEntry], + at: datetime) -> List[PublicKeyEntry]: + """The entries that may have signed something created at the moment, + best first: the entry in force, then the entry in force a tolerance + earlier and the entry in force a tolerance later where those + differ.""" + candidates: List[PublicKeyEntry] = [] + + def add(entry: Optional[PublicKeyEntry]) -> None: + if entry is not None and all(c is not entry for c in candidates): + candidates.append(entry) + + add(_in_force_at(keys, at)) + add(_in_force_at(keys, at - _BOUNDARY_TOLERANCE)) + add(_in_force_at(keys, at + _BOUNDARY_TOLERANCE)) + return candidates diff --git a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py index 87149e1..f0ff59d 100644 --- a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py +++ b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py @@ -22,12 +22,17 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from owid import Owid, Version from .id_type import IdType +#: The moment the envelope's date field counts minutes from, being the OWID +#: epoch of 2020-01-01T00:00:00Z. :attr:`FodId.date_minutes` is the unsigned +#: 32-bit count of minutes since this moment. +DATE_EPOCH = datetime(2020, 1, 1, tzinfo=timezone.utc) + class FodId: """A strongly typed reader for the 51Did (51Degrees Identifier) value @@ -126,14 +131,46 @@ def __init__(self, owid: Owid) -> None: @classmethod def from_base64(cls, base64: str) -> "FodId": - """Parses a 51Did from its base64-encoded OWID string. + """Parses a 51Did from its base64-encoded OWID string in either + alphabet. + + The cloud issues a 51Did in the standard alphabet with padding, and a + page puts one in a link in the URL-safe alphabet (``-`` and ``_``) + without padding. Both are accepted here, with or without padding, + by normalising to the standard form before decoding, so a server + never converts an identifier it received from a link. Raises :class:`TypeError` if ``base64`` is ``None`` and :class:`owid.OwidError` if it is not valid base64 or not a valid OWID. """ if base64 is None: raise TypeError("base64 must not be None") - return cls(Owid.from_base64(base64)) + return cls(Owid.from_base64(cls.to_standard_base64(base64))) + + @staticmethod + def to_standard_base64(value: str) -> str: + """Restores a base64 string in either alphabet to the standard + alphabet with padding, which is the form the OWID library decodes. + + ``-`` becomes ``+`` and ``_`` becomes ``/``, then ``==`` is added + when the length modulo 4 is 2 and ``=`` when it is 3. A value already + in the standard padded form is returned unchanged. + """ + cleaned = value.strip().replace("-", "+").replace("_", "/") + remainder = len(cleaned) % 4 + if remainder == 2: + cleaned += "==" + elif remainder == 3: + cleaned += "=" + return cleaned + + @staticmethod + def to_base64_url(value: str) -> str: + """Converts a base64 string in either alphabet to the URL-safe + alphabet without padding, the inverse of :meth:`to_standard_base64`, + so a 51Did can be placed in a URL without further encoding. + """ + return value.strip().replace("+", "-").replace("/", "_").rstrip("=") @classmethod def from_byte_array(cls, buffer: bytes) -> "FodId": @@ -172,7 +209,14 @@ def type(self) -> IdType: @property def license_id(self) -> int: - """The 4-byte little-endian License Id (0 to 4294967295).""" + """The raw value of the 4-byte little-endian License Id field + (0 to 4294967295). + + On an identifier carrying a creator context, the four bytes at + offset 1 hold an encrypted value that only 51Degrees can turn back + into a licence identifier, so this property is the field's raw value + and identifies nothing outside 51Degrees. + """ return self._license_id @property @@ -196,9 +240,20 @@ def domain(self) -> str: @property def date(self) -> datetime: - """The OWID creation date.""" + """The OWID creation date, as an aware UTC datetime.""" return self._owid.date + @property + def date_minutes(self) -> int: + """The envelope's own date as the unsigned 32-bit count of minutes + since :data:`DATE_EPOCH` (2020-01-01T00:00:00Z). + + This is the value the envelope carries on the wire and the value the + OWID ``public-key?date=`` parameter takes, so a caller comparing + creation times gets the integer rather than a converted date. + """ + return int((self._owid.date - DATE_EPOCH).total_seconds() // 60) + @property def payload(self) -> bytes: """The OWID payload bytes.""" @@ -210,9 +265,15 @@ def signature(self) -> bytes: return self._owid.signature def as_base64(self) -> str: - """Returns the OWID as a base64 string.""" + """Returns the OWID as a standard base64 string with padding, the + form the cloud issues.""" return self._owid.as_base64() + def as_base64_url(self) -> str: + """Returns the OWID as a URL-safe base64 string without padding, the + form to place in a URL. :meth:`from_base64` accepts it back.""" + return self.to_base64_url(self.as_base64()) + def as_byte_array(self) -> bytes: """Returns the OWID as a byte array including the signature.""" return self._owid.as_byte_array() diff --git a/fiftyone_pipeline_did/tests/envelope.py b/fiftyone_pipeline_did/tests/envelope.py new file mode 100644 index 0000000..7a00c2d --- /dev/null +++ b/fiftyone_pipeline_did/tests/envelope.py @@ -0,0 +1,191 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""Shared builders for the client tests: signed envelopes with a chosen +date and version, a key schedule as the cloud publishes one, and a +transport stand-in that records requests and answers from a script, so no +test touches the network.""" + +import json +import urllib.parse +from datetime import datetime, timedelta, timezone + +from owid import Crypto, Owid, Version + +from fiftyone_pipeline_did import FodId + +TEST_DOMAIN = "51degrees.com" + +#: The OWID epoch, the moment the envelope date counts minutes from. +EPOCH = datetime(2020, 1, 1, tzinfo=timezone.utc) + + +def probabilistic_payload(): + """A 37 byte payload of the Probabilistic type with recognisable + bytes.""" + payload = bytearray(FodId.PAYLOAD_LENGTH) + payload[FodId.FLAGS_OFFSET] = 0b0000_0101 + payload[FodId.LICENSE_ID_OFFSET:FodId.LICENSE_ID_OFFSET + 4] = \ + bytes([0x78, 0x56, 0x34, 0x12]) + for i in range(FodId.HASH_LENGTH): + payload[FodId.HASH_OFFSET + i] = 0x20 + i + return bytes(payload) + + +def random_payload(): + """A 21 byte payload of the Random type.""" + payload = bytearray(FodId.RANDOM_PAYLOAD_LENGTH) + payload[FodId.FLAGS_OFFSET] = (1 << 6) | 0b001 + for i in range(FodId.GUID_LENGTH): + payload[FodId.HASH_OFFSET + i] = 0x40 + i + return bytes(payload) + + +def context_payload(): + """A Probabilistic payload followed by a creator context + section. How long a section is belongs to the cloud and changes with + the section version, so an arbitrary length is used here.""" + return probabilistic_payload() + bytes([0]) + bytes(range(1, 24)) + + +def signed_envelope(crypto, payload, date=None, version=Version.VERSION3, + domain=TEST_DOMAIN): + """An OWID over the payload, signed with the key pair, dated as given + (to the minute, as the wire format stores it) and stamped with the + version. The Creator class always writes version 3 and the current + time, so the fields are set and signed by hand here.""" + if date is None: + date = datetime.now(timezone.utc) + owid = Owid(version=version, domain=domain, + date=date.replace(second=0, microsecond=0), + payload=bytes(payload)) + owid.signature = crypto.sign_byte_array(owid.data_for_crypto([])) + return owid + + +def signed_fod_id(crypto, payload=None, date=None, + version=Version.VERSION3, domain=TEST_DOMAIN): + """A FodId over a signed envelope, Probabilistic unless a payload is + given.""" + if payload is None: + payload = probabilistic_payload() + return FodId.from_owid(signed_envelope( + crypto, payload, date, version, domain)) + + +def iso_round_trip(moment): + """The moment as the cloud's ``o`` format writes it, with seven + fractional digits, which is what the key endpoint emits.""" + return moment.astimezone(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%f0Z") + + +class KeySchedule: + """Four weekly keys, Monday 00:00 UTC starts, each with its own key + pair, published the way the cloud publishes them.""" + + FIRST_START = datetime(2026, 8, 3, tzinfo=timezone.utc) + + def __init__(self, count=4, spacing=timedelta(days=7)): + self.entries = [] + for i in range(count): + crypto = Crypto.new() + self.entries.append((self.FIRST_START + spacing * i, crypto)) + + def start(self, index): + return self.entries[index][0] + + def crypto(self, index): + return self.entries[index][1] + + def json(self, start_field="startsAt"): + """The key list body. ``start_field`` is either supported start + field, ``startsAt`` or the compatibility field ``created``.""" + keys = [] + for starts_at, crypto in self.entries: + entry = {"publicKey": crypto.public_key_pem()} + if start_field == "startsAt": + entry["startsAt"] = iso_round_trip(starts_at) + entry["weekStart"] = iso_round_trip(starts_at) + entry["created"] = iso_round_trip( + starts_at - timedelta(days=90)) + else: + entry["created"] = iso_round_trip(starts_at) + keys.append(entry) + # Newest first, to prove the client sorts rather than trusts the + # order it was given. + keys.reverse() + return json.dumps(keys) + + +class FakeTransport: + """A transport that records every request and answers from a script + keyed on the path segment after the API base. Each answer is a + ``(status, body)`` pair, or a callable taking the request and returning + one, or an exception instance to raise.""" + + def __init__(self, answers=None): + self.answers = dict(answers or {}) + self.requests = [] + + def __call__(self, request): + self.requests.append(request) + path = urllib.parse.urlparse(request.full_url).path + for key, answer in self.answers.items(): + if key in path: + if isinstance(answer, BaseException): + raise answer + if callable(answer): + return answer(request) + status, body = answer + if isinstance(body, str): + body = body.encode("utf-8") + return status, body + return 404, b"no answer scripted for " + path.encode("utf-8") + + def count(self, segment): + """How many recorded requests carry the segment in their path.""" + return sum(1 for r in self.requests + if segment in urllib.parse.urlparse(r.full_url).path) + + def last(self): + return self.requests[-1] + + +def form_of(request): + """The form fields of a recorded POST, as a dict of single values.""" + data = request.data.decode("ascii") if request.data else "" + return {k: v[0] for k, v in urllib.parse.parse_qs( + data, keep_blank_values=True).items()} + + +class FixedClock: + """A clock the tests move by hand.""" + + def __init__(self, start): + self.now = start + + def __call__(self): + return self.now + + def advance(self, delta): + self.now = self.now + delta diff --git a/fiftyone_pipeline_did/tests/test_creator_context_server.py b/fiftyone_pipeline_did/tests/test_creator_context_server.py new file mode 100644 index 0000000..cd80f8e --- /dev/null +++ b/fiftyone_pipeline_did/tests/test_creator_context_server.py @@ -0,0 +1,180 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""The creator context example's ``/redeem`` route, driven over a real +socket against a ``DidClient`` whose transport is a stand-in, so the route +is tested as the page sees it and the cloud is never called.""" + +import importlib.util +import json +import os +import threading +import unittest +import urllib.error +import urllib.parse +import urllib.request +from datetime import timedelta +from http.server import HTTPServer +from pathlib import Path + +from fiftyone_pipeline_did import DidClient + +from .envelope import FakeTransport, KeySchedule, signed_fod_id + +SERVER_PY = Path(__file__).resolve().parents[1] / "examples" \ + / "creator_context_web" / "server.py" + +REDEEMED = json.dumps({ + "signature": "verified", + "context": "verified", + "verifiedAt": "2026-08-07T09:15:32Z", + "secondsSinceVerified": 2, +}) + + +def load_server(): + """Imports server.py by path. The module reads its environment at + import, so a resource key is set first, and the value is never used + here because the route under test takes its client from the class.""" + os.environ.setdefault("_51DEGREES_RESOURCE_KEY", "test-resource-key") + spec = importlib.util.spec_from_file_location( + "creator_context_server", SERVER_PY) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CreatorContextServerTests(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.server_module = load_server() + cls.schedule = KeySchedule() + cls.transport = FakeTransport({ + "id/key/": (200, cls.schedule.json()), + "id/redeem": (200, REDEEMED), + }) + + class QuietDemo(cls.server_module.Demo): + def log_message(self, *args): + pass + + QuietDemo.client = DidClient( + "test-resource-key", "test-licence-key", + "https://cloud.example/api/v4/", transport=cls.transport) + cls.http = HTTPServer(("127.0.0.1", 0), QuietDemo) + cls.thread = threading.Thread(target=cls.http.serve_forever, + daemon=True) + cls.thread.start() + cls.base = "http://127.0.0.1:{0}".format(cls.http.server_port) + + @classmethod + def tearDownClass(cls): + cls.http.shutdown() + cls.http.server_close() + + def setUp(self): + self.transport.answers["id/redeem"] = (200, REDEEMED) + self.fod_id = signed_fod_id( + self.schedule.crypto(1), + date=self.schedule.start(1) + timedelta(days=1)) + + def get(self, fifty_one_did, result="sealed", challenge="abc"): + url = "{0}/redeem?51did={1}&result={2}&challenge={3}".format( + self.base, fifty_one_did, urllib.parse.quote(result, safe=""), + challenge) + try: + with urllib.request.urlopen(url, timeout=10) as response: + return (response.status, + response.headers.get("Content-Type"), + response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8") + error.close() + return error.code, error.headers.get("Content-Type"), body + + def test_answers_in_the_cloud_shape_with_server_signature_added(self): + status, content_type, body = self.get(self.fod_id.as_base64_url()) + self.assertEqual(200, status) + self.assertEqual("application/json", content_type) + expected = json.loads(REDEEMED) + expected["serverSignature"] = "verified" + self.assertEqual(expected, json.loads(body)) + # The redeem the route made carried what the page sent, and the + # licence key the page never sees, all in the POST body. + redeem = [r for r in self.transport.requests + if r.full_url.endswith("id/redeem")][-1] + self.assertEqual("POST", redeem.get_method()) + form = {k: v[0] for k, v in urllib.parse.parse_qs( + redeem.data.decode("ascii")).items()} + self.assertEqual("sealed", form["result"]) + self.assertEqual("abc", form["challenge"]) + self.assertEqual("test-licence-key", form["license"]) + self.assertEqual(self.fod_id.as_base64_url(), form["51did"]) + + def test_forged_envelope_is_named_invalid_by_the_server(self): + from owid import Crypto + forged = signed_fod_id( + Crypto.new(), date=self.schedule.start(1) + timedelta(days=1)) + status, _, body = self.get(forged.as_base64_url()) + self.assertEqual(200, status) + self.assertEqual("invalid", json.loads(body)["serverSignature"]) + + def test_unparseable_identifier_is_a_400_with_errors(self): + status, content_type, body = self.get("not-a-51did") + self.assertEqual(400, status) + self.assertEqual("application/json", content_type) + self.assertIn("errors", json.loads(body)) + + def test_host_without_the_creator_context_answers_404_text(self): + self.transport.answers["id/redeem"] = (404, "Not Found") + status, content_type, body = self.get(self.fod_id.as_base64_url()) + self.assertEqual(404, status) + self.assertTrue(content_type.startswith("text/plain")) + self.assertEqual("Not Found", body) + + def test_503_unconfirmed_is_relayed_with_the_status(self): + self.transport.answers["id/redeem"] = ( + 503, '{"context":"unconfirmed"}') + status, _, body = self.get(self.fod_id.as_base64_url()) + self.assertEqual(503, status) + self.assertEqual("unconfirmed", json.loads(body)["context"]) + + def test_cloud_400_is_relayed_as_the_cloud_said_it(self): + self.transport.answers["id/redeem"] = ( + 400, '{"errors":["bad identifier"]}') + status, content_type, body = self.get(self.fod_id.as_base64_url()) + self.assertEqual(400, status) + self.assertEqual("application/json", content_type) + self.assertEqual(["bad identifier"], json.loads(body)["errors"]) + + def test_unreachable_cloud_answers_502_with_an_error(self): + self.transport.answers["id/redeem"] = urllib.error.URLError( + "connection refused") + status, content_type, body = self.get(self.fod_id.as_base64_url()) + self.assertEqual(502, status) + self.assertEqual("application/json", content_type) + self.assertIn("connection refused", json.loads(body)["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/fiftyone_pipeline_did/tests/test_did_client.py b/fiftyone_pipeline_did/tests/test_did_client.py new file mode 100644 index 0000000..9f39a13 --- /dev/null +++ b/fiftyone_pipeline_did/tests/test_did_client.py @@ -0,0 +1,748 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +import json +import os +import struct +import unittest +import urllib.error +import urllib.parse +from datetime import datetime, timedelta, timezone + +from owid import Crypto, Version + +from fiftyone_pipeline_did import ( + DEFAULT_ENDPOINT, + ContextResult, + DidArgumentError, + DidClient, + DidClientError, + DidNotSupportedError, + FactorResult, + FodId, + RedeemResult, + SignatureReason, + SignatureResult, +) +from fiftyone_pipeline_did.did_client import USER_AGENT, parse_iso8601 + +from .envelope import ( + EPOCH, + FakeTransport, + FixedClock, + KeySchedule, + context_payload, + form_of, + probabilistic_payload, + random_payload, + signed_envelope, + signed_fod_id, +) + +RESOURCE = "AQAAAAAAAAA-resource" +LICENCE = "licence-key-value" +ENDPOINT = "https://cloud.example/api/v4/" + + +class FodIdBase64Tests(unittest.TestCase): + """Section 1 of the run book: both alphabets, the URL-safe form and + the date as minutes.""" + + def setUp(self): + self.crypto = Crypto.new() + # A payload chosen so the base64 contains both + and / once + # encoded: every three bytes of 0xFB encode to "+/v7" whatever + # the alignment, and a 32 byte run holds several whole triples. + payload = bytearray(probabilistic_payload()) + for i in range(FodId.HASH_LENGTH): + payload[FodId.HASH_OFFSET + i] = 0xFB + self.fod_id = signed_fod_id(self.crypto, bytes(payload)) + self.standard = self.fod_id.as_base64() + self.assertTrue("+" in self.standard or "/" in self.standard, + "the fixture must exercise both alphabets") + + def test_standard_and_url_safe_forms_parse_to_the_same_envelope(self): + url_safe = self.fod_id.as_base64_url() + url_safe_padded = self.standard.replace("+", "-").replace("/", "_") + self.assertNotEqual(self.standard, url_safe) + self.assertFalse(url_safe.endswith("=")) + for form in (self.standard, url_safe, url_safe_padded, + self.standard.rstrip("=")): + parsed = FodId.from_base64(form) + self.assertEqual(self.fod_id.as_byte_array(), + parsed.as_byte_array(), form) + + def test_surrounding_whitespace_parses_to_the_same_value(self): + # A value copied from a header, a form field or a text file + # can arrive with a newline or a space around it. + for form in (self.standard + "\n", " " + self.standard, + self.standard + " ", + " " + self.fod_id.as_base64_url() + "\n"): + parsed = FodId.from_base64(form) + self.assertEqual(self.fod_id.as_byte_array(), + parsed.as_byte_array(), repr(form)) + + def test_as_base64_url_round_trips(self): + again = FodId.from_base64(self.fod_id.as_base64_url()) + self.assertEqual(self.fod_id.as_base64_url(), again.as_base64_url()) + self.assertEqual(self.standard, again.as_base64()) + + def test_helpers_invert_each_other(self): + url_safe = FodId.to_base64_url(self.standard) + self.assertNotIn("+", url_safe) + self.assertNotIn("/", url_safe) + self.assertNotIn("=", url_safe) + self.assertEqual(self.standard, FodId.to_standard_base64(url_safe)) + self.assertEqual(self.standard, + FodId.to_standard_base64(self.standard)) + + def test_date_minutes_is_the_envelope_field(self): + minutes = 3_456_789 + fod_id = signed_fod_id( + self.crypto, date=EPOCH + timedelta(minutes=minutes)) + self.assertEqual(minutes, fod_id.date_minutes) + # Read the four date bytes straight off the wire: version byte, + # the domain and its terminator, then the little-endian minutes. + raw = fod_id.as_byte_array() + offset = 1 + len(fod_id.domain.encode("utf-8")) + 1 + self.assertEqual( + minutes, struct.unpack("") + + def test_transport_failure_propagates_as_the_io_error(self): + self.transport.answers["id/redeem"] = urllib.error.URLError( + "connection refused") + with self.assertRaises(OSError): + self.client.redeem(self.fod_id, "sealed-result", "abc123") + + def test_string_identifier_is_sent_as_given(self): + self.transport.answers["id/redeem"] = (200, '{"context":"unreadable"}') + self.client.redeem("AwB-_x", "sealed-result", None) + form = form_of(self.transport.last()) + self.assertEqual("AwB-_x", form["51did"]) + self.assertEqual("", form["challenge"]) + + def test_redeem_refuses_far_too_long_text_before_the_form(self): + with self.assertRaises(ValueError): + self.client.redeem("A" * 5000, "sealed-result") + self.assertEqual(0, len(self.transport.requests)) + + def test_result_class_can_be_built_from_a_response_directly(self): + result = RedeemResult.from_response(200, REDEEMED_WITHOUT_FACTORS) + self.assertEqual(ContextResult.VERIFIED, result.context) + + +class OpenerTransportTests(unittest.TestCase): + """An urllib opener is accepted as the transport, with a non-2xx + answer read from the HTTPError rather than raised.""" + + def test_opener_answers_are_read_whatever_the_status(self): + class Response: + def __init__(self, status, body): + self.status = status + self._body = body + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + class Opener: + def __init__(self): + self.calls = [] + + def open(self, request, timeout=None): + self.calls.append(request) + if "id/verify/" in request.full_url: + raise urllib.error.HTTPError( + request.full_url, 400, "Bad Request", {}, + _Bytes(b'{"valid":false}')) + return Response(200, b'{"valid":true}') + + class _Bytes: + def __init__(self, data): + self._data = data + + def read(self): + return self._data + + def close(self): + pass + + opener = Opener() + client = DidClient(RESOURCE, endpoint=ENDPOINT, transport=opener) + self.assertFalse(client.verify("AwB-_x")) + self.assertEqual(1, len(opener.calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/fiftyone_pipeline_did/tests/test_did_client_live.py b/fiftyone_pipeline_did/tests/test_did_client_live.py new file mode 100644 index 0000000..67d202d --- /dev/null +++ b/fiftyone_pipeline_did/tests/test_did_client_live.py @@ -0,0 +1,101 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""Live tests against the cloud, run only when a resource key is set. + +The key is read from ``resource_key``, the variable the repository's other +live tests and its CI use, or from ``_51DEGREES_RESOURCE_KEY`` as the +example reads it. Without either the tests are skipped, not failed. An +optional licence key is read from ``license_key`` or +``_51DEGREES_LICENSE_KEY``, and the endpoint from ``FOD_CLOUD_API_URL`` as +everywhere else. Every test here costs uses against the resource key. +""" + +import json +import os +import unittest +import urllib.parse +import urllib.request + +from fiftyone_pipeline_did import ( + ContextResult, + DidClient, + DidNotSupportedError, + FodId, +) +from fiftyone_pipeline_did.did_client import USER_AGENT + +RESOURCE_KEY = os.environ.get("resource_key") \ + or os.environ.get("_51DEGREES_RESOURCE_KEY") +LICENCE_KEY = os.environ.get("license_key") \ + or os.environ.get("_51DEGREES_LICENSE_KEY") + + +@unittest.skipUnless( + RESOURCE_KEY, + "set resource_key (or _51DEGREES_RESOURCE_KEY) to run the live tests") +class DidClientLiveTests(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.client = DidClient(RESOURCE_KEY, LICENCE_KEY) + + def create(self): + """Creates a 51Did through the cloud ``json`` endpoint, the route + the cloud request engine calls, for this test's own connection.""" + url = "{0}{1}.json".format(self.client.endpoint, + urllib.parse.quote(RESOURCE_KEY, safe="")) + request = urllib.request.Request( + url, data=b"", headers={"User-Agent": USER_AGENT}, + method="POST") + with urllib.request.urlopen(request, timeout=30) as response: + body = json.loads(response.read().decode("utf-8")) + fodid = body.get("fodid") or {} + value = fodid.get("idproblic") or fodid.get("idprobglobal") + self.assertTrue( + value, "the resource key must include a 51Did property " + "(idprobglobal or idproblic); the json answer was: " + + json.dumps(body)[:300]) + return FodId.from_base64(value) + + def test_created_identifier_verifies_offline_and_through_the_cloud(self): + fod_id = self.create() + self.assertIsNotNone(self.client.public_key_for(fod_id)) + self.assertTrue(self.client.verify_signature(fod_id)) + self.assertTrue(self.client.verify(fod_id)) + # The URL-safe form a page would send round-trips through the + # cloud as well. + self.assertTrue(self.client.verify(fod_id.as_base64_url())) + + def test_garbage_result_redeems_as_unreadable(self): + fod_id = self.create() + try: + result = self.client.redeem(fod_id, "not-base64url!!", "x") + except DidNotSupportedError as error: + self.skipTest("the host does not offer the creator context: " + + str(error)) + self.assertEqual(200, result.status_code) + self.assertEqual(ContextResult.UNREADABLE, result.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/fiftyone_pipeline_did/tests/test_fodid.py b/fiftyone_pipeline_did/tests/test_fodid.py index d184467..7e1388a 100644 --- a/fiftyone_pipeline_did/tests/test_fodid.py +++ b/fiftyone_pipeline_did/tests/test_fodid.py @@ -208,6 +208,19 @@ def test_payload_larger_than_spec_uses_first_37_bytes(self): self.assertEqual(CANONICAL_HASH, fod.hash) self.assertEqual(FodId.HASH_LENGTH, len(fod.hash)) + def test_long_envelope_parses_and_keeps_the_header_fields(self): + # No upper bound belongs in the reader: a creator domain is a + # deployment parameter and a context section of a version this + # package does not know about may be any length. + payload = bytearray(canonical_payload()) + bytearray(200) + owid = Owid(domain="identifiers." + ("a" * 120) + ".example", + payload=bytes(payload), signature=bytes(64)) + fod = FodId.from_base64(owid.as_base64()) + self.assertEqual(CANONICAL_FLAGS, fod.flags) + self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) + self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(FodId.HASH_LENGTH, len(fod.hash)) + def test_is_cryptographically_verifiable(self): fod = FodId.from_base64( self.factory.signed_owid_base64(canonical_payload()))