diff --git a/CHANGELOG.md b/CHANGELOG.md index ece3467..acba1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.19.0 — 2026-08-29 + +- advances the DataTiles schema to revision 8 with CF-first semantic variables, FAIR publication metadata, provenance, rights, optional integrity signatures, optional protected distribution, and immutable release identity; +- adds deterministic NetCDF, GRIB2, and Zarr scientific import utilities that preserve numeric DNT1 values and record source and conversion provenance; +- adds the optional Flask/SQLAlchemy DataTiles Store PWA, API, authentication, agreement enforcement, provider-neutral commerce, user libraries, and update discovery; +- expands the normative specification, migration addenda, source-citation register, white paper, tutorials, and regression coverage for revisions 4 through 8. + ## 0.10.0 — 2026-08-27 - adds a command-line static-demo exporter, a Safari-compatible client-side numeric/vector portrayal, the full scientific playground toolset (relief, contours, smart labels, profile, compound query, and interactive 3D mesh), embedded checksum-identified data for direct `file://` use, an offline OpenLayers bundle, and an optional loopback-only launcher; diff --git a/CITATION.cff b/CITATION.cff index c4508ff..1726ede 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - family-names: Montella given-names: Raffaele -version: 0.10.0 +version: 0.19.0 date-released: 2026-08-27 license: Apache-2.0 keywords: diff --git a/README.md b/README.md index a605329..0ca3162 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ The spatial row follows MBTiles/TMS convention. Use `--xyz` on `put` and `get` t Version 0.10 adds conservative physical-table MBTiles fallback, a self-sufficient implementation specification, and an onboard edge-intelligence manifesto/white paper. It also includes mixed raster/vector content profiles, a tested five-lesson zero-to-hero curriculum, FAIR-by-design publication profile, OpenLayers scientific playground, comprehensive quality suite, protected CI/CD release path, interval axes, PROV-inspired provenance, scientific CRS records, bounded numeric-array decoding, OpenAPI description, and read-only OGC-style access. +## NetCDF, GRIB, and Zarr import utilities + +Optional import utilities under `utils/` convert local or URL-identified NetCDF, GRIB2, and Zarr rectilinear grids into semantic DNT1 DataTiles. They preserve CF Standard Names when present, add GRIB2 crosswalk identifiers when available, retain source identity plus format-appropriate checksum provenance, and never pre-render scientific arrays as imagery. Install with `python -m pip install -e '.[utils]'`; see `docs/import-utilities.md`. + ## Raster and vector content - Numeric raster matrices use the dependency-free DNT1 encoding and retain dtype, shape, byte order, nodata, scale, offset, unit, and compression. @@ -69,6 +73,18 @@ The release workspace may also contain `dist/from-gaeta-to-maratea.datatiles` an Repository guidance is in [`AGENTS.md`](AGENTS.md), and the Markdown license notice is in [`LICENSE.md`](LICENSE.md). The complete Apache-2.0 legal text remains in `LICENSE`. +## Zarr source ingestion + +DataTiles 0.13 adds `utils/zarr2datatiles.py` for Zarr v2/v3 stores. Local directory stores use the canonical `zarr-tree-sha256-v1` store digest; remote stores must be bound to an immutable snapshot with an authoritative checksum. Groups, consolidated metadata policy, fsspec storage backends, CF semantic mapping, explicit source/output rights, and credential-safe provenance are documented in `docs/zarr-source-profile.md`. + +## Optional cryptographic integrity and signatures + +DataTiles schema revision 6 can cryptographically bind the final logical scientific object to a signer without making signatures mandatory. `datatiles-integrity` creates deterministic SHA-256 logical manifests and optional Ed25519 signatures, either embedded or detached. Verification explicitly distinguishes mathematical validity from trust in the signer: an embedded public key is not, by itself, an authenticated publisher identity. The dependency-free core remains unchanged; install `.[integrity]` only when signing/verification is required. See `docs/digital-signatures.md`. + +## FAIR scientific publication + +DataTiles 0.12 treats FAIR, provenance, and licensing as enforceable publication evidence. Published objects separate persistent identity, DataCite 4.7 citation metadata, W3C PROV-aligned lineage, SPDX rights for the generated dataset/metadata and each source, cryptographic source identity, and external repository evidence. `fair_report(strict_publication=True)` is a release gate; it does not claim that FAIRness proves scientific validity or navigational fitness. See `docs/fair-publication-profile.md`, `docs/provenance.md`, and `docs/data-licensing.md`. + ## Quality assurance and releases Pull requests run the complete suite on Python 3.10–3.13, validate playground JavaScript, build and install the distributions, and repeat the deterministic scientific-fixture build. Version tags additionally produce checksums, a GitHub build-provenance attestation, a GitHub Release, and—after the protected environment gate—a PyPI Trusted Publishing deployment. See [`docs/testing-and-release.md`](docs/testing-and-release.md) for the local protocol and repository configuration. @@ -104,6 +120,24 @@ The [onboard intelligence white paper](docs/white-paper.md) presents DataTiles a New users can follow the [five-lesson DataTiles zero-to-hero tutorial](docs/tutorial/README.md), which combines formal discussion with a fully offline mixed raster/vector laboratory dataset. +## Data acknowledgement and citation — required reading + +Data Tiles demo derivatives are multi-source scientific products. Every map, MBTiles file, figure, service, paper, and release must visibly credit every dataset that actually contributed cells or features and must ship its machine-readable provenance. The MIT software licence does not relicense any input data. +The authoritative, source-by-source register is docs/data_sources_and_citation.md. It gives full bibliographic citations, mandatory map acknowledgements, licences, persistent identifiers, coverage/resolution limitations, frozen-manifest locations, and a FAIR release checklist. + +For the current From Gaeta to Maratea production build, acknowledge: + +JammeGaia22/MGDS bathymetry: Foglini, F., Tonielli, R., & Rovere, M. (2024), Multi-Resolution bathymetry grids of the Naples and Pozzuoli Gulf and Amalfi Coastal Area, Jamme_Gaia22 (2022), https://doi.org/10.60521/331667, CC BY 4.0. +EMODnet fallback bathymetry: EMODnet Bathymetry Consortium (2024), EMODnet Digital Bathymetry (DTM 2024), https://doi.org/10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1, CC BY 4.0. EMODnet is used only where JammeGaia22 contains no finite measurement. +Land/coastline: GSHHG 2.3.7 (Wessel & Smith, 1996, https://doi.org/10.1029/96JB00104) and, when enabled, S2Coast-2023 (Duan et al., https://doi.org/10.5281/zenodo.17092775, CC BY 4.0). These remain separate topology and high-water-line facts. +Navigation and land context: © OpenStreetMap contributors, ODbL 1.0, https://www.openstreetmap.org/copyright. +The required short-form map credit is: + +Bathymetry: Foglini, Tonielli & Rovere (2024), JammeGaia22/MGDS, doi:10.60521/331667; EMODnet Bathymetry Consortium DTM 2024, doi:10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1. Land/coastline: GSHHG 2.3.7 and S2Coast-2023. Context: © OpenStreetMap contributors, ODbL 1.0. Not for navigation. +Do not list GMRT, GEBCO, EMODnet thematic products, or ISPRA as contributors unless the specific run manifest proves that they were used. Citation is source-specific evidence, not a generic project boilerplate. + +> **Software-licence consistency check.** The current public DataTiles repository declares Apache-2.0 in `LICENSE`. The preceding requested wording says MIT. Before publication, the README wording and `LICENSE` MUST agree; regardless of which software licence is adopted, it does not relicense input data. + ## How to cite DataTiles Research using the software SHOULD cite the released software described by [`CITATION.cff`](CITATION.cff). Also cite the paper(s) that support the part of the scientific lineage or application you use: @@ -121,3 +155,17 @@ This repository is a working reference implementation and a draft format specifi ## License Apache-2.0. + +## Optional commercial DRM + +DataTiles can optionally package a finalized lawful commercial product as an encrypted `.dtpkg` with recipient-specific issuer-signed licence grants. The portable profile uses AES-256-GCM, X25519/HKDF key wrapping, Ed25519 issuer signatures, and W3C ODRL 2.2 policy metadata. DRM is a technical access-control mechanism only: it does not relicense source data, remove attribution obligations, establish ownership, prove scientific validity, or make a product suitable for navigation. See `docs/drm-and-commercial-licensing.md`. + +## DataTiles Store PWA + +The optional `/store` application is a Python/Flask progressive web application backed by SQLAlchemy. It indexes machine-readable metadata from stored DataTiles files into a separate application database, supports full catalog search, authenticated browsing, role/group based authorization, an interactive chart-explorer-style preview of declared portrayal slices, and authorized downloads of the original DataTiles files. The bootstrap `admin` user belongs to the `administrators` group with the `admin` role; its initial password is explicitly configured in `store/config.py` and must be changed/secret-managed for production. The PWA never treats numeric scientific matrices as imagery and its service worker does not cache protected APIs, map payloads, or downloads. See `docs/store-pwa.md`. Managed authentication, verified-email self-registration, Google OIDC, Microsoft Entra ID tenants, generic OAuth2/OIDC, SMTP, public callback URL, and agreement settings are administrator-configurable in the PWA and API. Complete Store help is maintained under `store/docs/` and rendered by the PWA Help section. + +## Release versioning and optional Store payments + +Schema revision 8 defines `DataTiles-Release-Versioning-1`: a release can carry stable product identity, a human version label, a monotonically increasing release sequence, timestamp, predecessor, release-notes URI, and update-discovery URI. Published versioned releases are immutable; corrections and updates are new DataTiles objects with new checksums and signatures where used. + +The optional Store 0.4 adds a provider-neutral payment interface, with PayPal Orders v2 as the reference adapter, plus release-specific purchase entitlements, exact download history, a user library, and update notifications for users who previously acquired older sequences. Payment is disabled by default and is legally independent from data-licence acceptance: paying for a product never creates rights that the publisher does not possess. See `store/docs/payments.md` and `store/docs/versioning-and-updates.md`. diff --git a/demo/from-gaeta-to-maratea/CITATION.cff b/demo/from-gaeta-to-maratea/CITATION.cff index cd11d47..8f36068 100644 --- a/demo/from-gaeta-to-maratea/CITATION.cff +++ b/demo/from-gaeta-to-maratea/CITATION.cff @@ -5,6 +5,6 @@ type: software authors: - family-names: Montella given-names: Raffaele -version: 0.10.0 +version: 0.19.0 date-released: 2026-08-27 license: Apache-2.0 diff --git a/demo/from-gaeta-to-maratea/README.md b/demo/from-gaeta-to-maratea/README.md index 08b5d61..3316ade 100644 --- a/demo/from-gaeta-to-maratea/README.md +++ b/demo/from-gaeta-to-maratea/README.md @@ -89,3 +89,19 @@ After `make all`, run `datatiles-serve work/gaeta-to-maratea.datatiles --port 80 EMODnet is acquired at 2,754 × 1,167 regional cells; the compositing grid is 5,508 × 2,334 (nominal 50 m target spacing) so measured Jamme detail is not first collapsed to the coarser fallback grid. Storage extends through Web Mercator zoom 12. The client requests a bounded 128 × 128 map portrayal and a separate 96 × 72 analytic surface. The source-coverage layer makes every Jamme/EMODnet decision inspectable. Contour density is scale-dependent and remains limited by the selected source resolution; no new soundings are invented. The shelter field is a finite north-west ray test over the bathymetry water/land mask. It is a reproducible exposure proxy, not a wind-wave or atmospheric model, and must not support navigational or safety decisions. + +## FAIR, provenance, and rights gate + +The demo is also a publication-evidence exercise. Every upstream dataset must be represented by a checksum-identified provenance entity and an explicit source-rights record; the generated DataTiles dataset and its metadata have separate rights. The build must never infer permission from successful HTTP access. Before a scholarly release, replace illustrative identifiers with a registered PID, export DataCite 4.7 metadata and the W3C PROV-aligned graph, and archive the strict FAIR report with catalogue, landing-page, and metadata-retention evidence. `fair-publication-profile.json` lists the required evidence. The navigation warning remains independent of FAIR status: FAIRness does not establish hydrographic authority or safety fitness. + +## Source-specific citation gate + +`docs/data_sources_and_citation.md` is the authoritative citation register. A production run MUST record whether each acquired source actually contributed cells/features. Visible map/release acknowledgements MUST be checked against that frozen manifest: JammeGaia22 is primary bathymetry; EMODnet DTM 2024 is credited only when fallback cells are used; GSHHG topology and optional S2Coast-2023 high-water-line facts remain distinct; OpenStreetMap context retains ODbL attribution. Do not credit GMRT, GEBCO, EMODnet thematic products, ISPRA, or other candidates unless the run evidence proves contribution. + +## Optional signed release + +After the final source-contribution manifest, citations, rights, provenance, and FAIR checks are frozen, the demo MAY be signed with `datatiles-integrity`. Do not auto-sign intermediate NetCDF/GRIB/Zarr imports. The release should publish the detached signature and independently authenticated public key. A valid signature does not change the `Not for navigation` status. + +## Optional commercial edition + +The reference demo is not automatically converted into a proprietary product. A commercial edition may be created only if the frozen contribution manifest and every upstream licence permit that use. If enabled, register product/ODRL metadata in the inner DataTiles object, complete all acknowledgements, sign the frozen plaintext release if desired, and only then create `.dtpkg` packages and recipient licences. `Not for navigation` remains mandatory unless an independent competent authority establishes otherwise. diff --git a/demo/from-gaeta-to-maratea/fair-publication-profile.json b/demo/from-gaeta-to-maratea/fair-publication-profile.json new file mode 100644 index 0000000..8011d9a --- /dev/null +++ b/demo/from-gaeta-to-maratea/fair-publication-profile.json @@ -0,0 +1,25 @@ +{ + "profile": "DataTiles FAIR scholarly demonstration", + "purpose": "Template only; replace illustrative identifiers with registered publication evidence", + "required_evidence": [ + "primary PID", + "dataset rights", + "metadata rights", + "source rights per provenance entity", + "source SHA-256 locks", + "W3C PROV-aligned graph", + "DataCite 4.7 metadata export", + "catalogue registration", + "landing page", + "metadata retention policy", + "scientific QA and limitations" + ], + "warning": "Demo outputs are research/visualization artifacts and are not for navigation.", + "optional_integrity_evidence": [ + "DataTiles-Integrity-Manifest-1", + "Ed25519 detached/embedded signature", + "independently authenticated public key or certificate", + "optional Sigstore/timestamp/transparency evidence" + ], + "integrity_warning": "Signature validity does not establish scientific validity, legal compliance, hydrographic authority, or navigation fitness." +} diff --git a/demo/from-gaeta-to-maratea/runtime-lock.json b/demo/from-gaeta-to-maratea/runtime-lock.json index bb88e20..e405309 100644 --- a/demo/from-gaeta-to-maratea/runtime-lock.json +++ b/demo/from-gaeta-to-maratea/runtime-lock.json @@ -4,5 +4,5 @@ "zlib": "1.3.2", "numpy": "2.3.5", "pillow": "12.3.0", - "datatiles": "0.10.0" + "datatiles": "0.19.0" } diff --git a/docs/README.md b/docs/README.md index 76f324b..9e49e03 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,16 +10,27 @@ The [figure provenance register](figures/README.md) identifies the source docume | [MBTiles fallback](mbtiles-fallback.md) | selected-slice compatibility and physical-table export for conservative OpenLayers adapters | | [Onboard intelligence white paper](white-paper.md) | manifesto, AI evidence contract, practical marine/automotive architecture, and safety boundaries | | [Design evaluation](design-evaluation.md) | concise definition, audit findings, corrections, and compatibility conclusion | -| [Zero-to-hero tutorial](tutorial/README.md) | five theory-led lessons with executable raster/vector laboratories | +| [Zero-to-hero tutorial](tutorial/README.md) | seven theory-led lessons with executable raster/vector laboratories | | [FAIR by design](fair-by-design.md) | operational mapping from each FAIR principle to DataTiles evidence and publication gates | +| [FAIR scientific publication profile](fair-publication-profile.md) | normative-supporting FAIR gates, DataCite 4.7 alignment, and release evidence | +| [Scientific provenance](provenance.md) | W3C PROV-aligned entities, activities, agents, lineage, and audit rules | +| [Data licensing and rights](data-licensing.md) | SPDX expressions, source/output/metadata rights separation, attribution, and access conditions | +| [Cryptographic integrity and digital signatures](digital-signatures.md) | optional SHA-256 logical manifests, Ed25519 signatures, trust policy, offline verification, and Sigstore/in-toto interoperability | +| [Data sources and citation](data_sources_and_citation.md) | authoritative source-by-source citations, acknowledgements, licences, frozen-manifest rules, and FAIR release checklist | | [Architecture](architecture.md) | component boundaries and design rationale | | [Numeric tiles](numeric-tiles.md) | DNT1 encoding and physical-value semantics | | [Vector tiles](vector-tiles.md) | multidimensional MVT/GeoJSON profiles and MBTiles projection | | [HTTP/OGC API](ogc-api.md) | resource model and interoperability scope | | [Playground](playground.md) | OpenLayers scientific operations and limitations | +| [DataTiles Store PWA](store-pwa.md) | Flask/SQLAlchemy institutional catalog, metadata search, role-based access, interactive preview, licence/safety acceptance, API-first CRUD, and authorized downloads | +| [DataTiles Store API v1](store-api.md) | Bearer authentication, agreement workflow, catalog/search, preview/download, managers CRUD, identity administration, and audit endpoints | | [Testing and release](testing-and-release.md) | local suite, CI gates, protected delivery, and release checklist | | [Profile demo](profile-demo.md) | two-point transect algorithm and evidence | | [Reproducibility](reproducibility.md) | exact reconstruction of the Naples reference object | | [Replicability](replicability.md) | protocol for independent regions, sources, variables, and implementations | | [Getting started](getting-started.md) | concise installation and API tutorial | +| [Interoperability conventions](conventions.md) | CF-first variable semantics, units, CRS/datum, source identity, dimensions, and portrayal boundaries | +| [Import utilities](import-utilities.md) | NetCDF/GRIB/Zarr local-or-URL ingestion, semantic mapping, numeric tiling, and provenance | +| [Zarr source profile](zarr-source-profile.md) | normative Zarr v2/v3 source identity, checksums, groups, remote stores, FAIR provenance and rights | | [References](references.md) | research papers, standards, vocabularies, and source records | +| [Commercial DRM](drm-and-commercial-licensing.md) | optional protected distribution, ODRL policy, recipient licences | diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..a240f4a --- /dev/null +++ b/docs/conventions.md @@ -0,0 +1,43 @@ +# DataTiles interoperability conventions + +This document defines the interoperability conventions used by the semantic-variable extension and the NetCDF/GRIB/Zarr import utilities. The container specification remains authoritative for byte-level and schema behavior; these conventions define how common scientific concepts should be represented so independently developed software can discover and compare them. + +## Variable identity + +A DataTiles coordinate named `variable` is a producer-facing token used in multidimensional addresses. It is not, by itself, the scientific definition of the quantity. Every token should resolve through the semantic variable registry. + +CF Standard Names are the preferred canonical vocabulary when a valid CF concept exists. A producer-local variable without a valid CF name must use an explicitly different vocabulary such as `source`; software must never manufacture a CF name merely to satisfy validation. + +External identifiers are crosswalks. WMO GRIB2 discipline/category/parameter, GRIB short names, SeaDataNet/NERC vocabulary URIs, GCMD identifiers and provider-specific codes can all coexist for one semantic variable. + +## Units + +Units remain explicit at both semantic-registry and numeric-payload levels. A CF Standard Name does not justify discarding the source unit. Validation against a pinned CF table can verify the declared CF canonical unit; physical equivalence between alternate units requires a UDUNITS-capable validation profile rather than string guessing. + +## Vertical reference and CRS + +Quantities involving depth, height or elevation must preserve the relevant vertical reference. Names such as `sea_floor_depth_below_geoid` and `sea_floor_depth_below_mean_sea_level` are not interchangeable. CRS/datum information remains first-class metadata even when the CF name carries part of that meaning. + +Spatial tiles are stored using MBTiles/TMS row convention. XYZ is an interface convention and is converted at the boundary. Import utilities target Web Mercator (`EPSG:3857`) and must declare their resampling method. + +## Time and other scientific dimensions + +Time, pressure, depth/elevation, ensemble, model run, scenario, band and similar axes remain typed DataTiles dimensions. They must not be hidden in filenames or overloaded into variable names. Source dimensions that are not common to every imported variable may be optional, allowing heterogeneous variables to coexist in one container. + +## Numeric data versus portrayal + +Scientific numeric arrays are encoded as DNT1 and declared as `application/vnd.datatiles.numeric`. PNG, JPEG and WebP are portrayals, not substitutes for numeric evidence. Any portrayal used for MBTiles compatibility should be generated as a documented, provenance-linked derivation. + +## Source identity and provenance + +A source can be local or URL-identified. Single-file NetCDF/GRIB imports retain the original identifier and SHA-256 of the exact bytes used. Zarr requires a store-aware identity rule because it is normally a set of named objects: local stores use the canonical `zarr-tree-sha256-v1` digest defined in `zarr-source-profile.md`, while remote stores require an authoritative checksum for an immutable published store/snapshot. A transport location is provenance, not a semantic variable identifier. Reproducible workflows should prefer immutable/versioned source identifiers and independently published checksums. + +## Import algorithm declaration + +Conversion is a scientific transformation. The importer must expose the source grid assumptions, target CRS/grid, resampling algorithm, tile size, bounding region, selected variables and source checksum. Unsupported geometry must be rejected rather than silently approximated. + +The initial utilities support rectilinear one-dimensional latitude/longitude grids and nearest-neighbour resampling to Web Mercator tile pixel centers. Curvilinear grids, rotated grids, projected native grids and conservative area remapping require separate declared algorithms. + +## Integrity convention + +Checksums identify source objects and reproducible derivations; optional revision-6 signatures attest a canonical final DataTiles manifest. Do not use a signature as a replacement for source checksums, provenance, semantic names, units, CRS/datum, citation, or rights. The native integrity profile is defined in `digital-signatures.md`. diff --git a/docs/data-licensing.md b/docs/data-licensing.md new file mode 100644 index 0000000..85955b1 --- /dev/null +++ b/docs/data-licensing.md @@ -0,0 +1,19 @@ +# Data licensing and rights + +DataTiles never infers legal permission from technical accessibility. A URL that returns bytes establishes transport, not a licence. + +Each imported source MUST have a `source` rights record bound to its provenance entity. Each publishable object MUST separately declare `dataset` and `metadata` rights. Records contain an SPDX expression, authoritative terms URI, attribution, access classification, optional rights holder and the scope to which the terms apply. + +The converter utilities therefore require `--source-license`, `--source-license-uri`, `--source-attribution`, `--dataset-license`, and `--dataset-license-uri`. This forces the operator to make the legal basis explicit before ingestion. Use `LicenseRef-*` for proprietary, bespoke, or otherwise non-SPDX terms; do not guess a standard licence. + +Licensing compatibility is a legal/policy determination and is deliberately not automated by DataTiles. The software can validate that records exist and are syntactically machine actionable; it cannot decide whether combining two sources is legally permissible. Publication workflows SHOULD include a human rights review and retain its decision outside credentials/secrets. + +Attribution displayed in a playground is a convenience, not the sole legal record. The complete source-rights manifest travels with the DataTiles object and publication evidence. + +## Signatures do not alter rights + +Digital signatures attest integrity/authenticity; they do not grant copyright permission, sublicense upstream datasets, or resolve licence compatibility. Rights evaluation must be complete before a release is signed, and any later rights correction produces a new manifest and therefore requires a new signature. + +## Commercial licensing and DRM + +Commercial DRM may enforce an access grant only after the publisher has established the legal right to commercialize every contributing source. W3C ODRL policy and a DataTiles commercial licence are additional machine-readable/access-control layers; they do not replace SPDX/source rights records and cannot narrow attribution or other mandatory upstream notices invisibly. diff --git a/docs/data_sources_and_citation.md b/docs/data_sources_and_citation.md new file mode 100644 index 0000000..62f7139 --- /dev/null +++ b/docs/data_sources_and_citation.md @@ -0,0 +1,175 @@ +# Data sources, acknowledgement, licensing, and citation + +## Status and scope + +This document is the authoritative source-by-source citation register for DataTiles demonstration derivatives, especially the **From Gaeta to Maratea** production workflow. It is part of the release evidence, not optional narrative documentation. + +A source MUST be credited only when the frozen run manifest and provenance graph prove that the source contributed cells, features, topology, or contextual data to the released derivative. Conversely, every source that actually contributed MUST appear in the human-readable acknowledgement and machine-readable provenance/rights records. + +Data licensing is independent of software licensing. The repository's current public `LICENSE` is Apache-2.0; no software licence grants rights in third-party input data. If the repository software licence changes, this document and the README statement MUST be reconciled in the same release. + +## Mandatory release rule + +Every map, MBTiles export, DataTiles container, static playground, figure, service, paper, archive, and release derived from the demo MUST: + +1. identify the exact frozen source objects used by the run; +2. preserve source URI/PID, cryptographic identity or documented immutable-store identity, retrieval time, licence and required attribution; +3. record the transformation activity, software/version, parameters, CRS/datum handling, resampling/masking/fallback decisions, and generated-object identity; +4. visibly acknowledge every source that contributed to the displayed or distributed result; +5. distribute machine-readable provenance and rights with the artifact; +6. avoid crediting sources that were merely candidates, configured fallbacks, documentation references, or unused downloads; and +7. retain the **Not for navigation** warning for the research/demo products. + +The frozen manifest is the evidentiary boundary. A generic project-level acknowledgement MUST NOT override it. + +## Current From Gaeta to Maratea production sources + +### 1. Primary bathymetry — JammeGaia22 / MGDS + +**Role.** Primary finite bathymetric measurements wherever the selected JammeGaia22 grids contain usable values. + +**Bibliographic citation.** Foglini, F., Tonielli, R., & Rovere, M. (2024). *Multi-Resolution bathymetry grids of the Naples and Pozzuoli Gulf and the Amalfi Coastal Area collected during cruise Jamme_Gaia22, 2022*. Marine Geoscience Data System (MGDS). https://doi.org/10.60521/331667 + +**Persistent identifier.** DOI `10.60521/331667`. + +**Licence.** Creative Commons Attribution 4.0 International (CC BY 4.0). + +**Required acknowledgement.** `Bathymetry: Foglini, Tonielli & Rovere (2024), JammeGaia22/MGDS, doi:10.60521/331667.` + +**Scientific limitations.** This is multi-resolution multibeam bathymetry rather than a uniform-resolution navigation surface. MGDS describes GeoTIFF grids whose nominal resolutions vary with depth (2, 5, 15, 20, 30 and 40 m in the published products). Users MUST preserve the source grid identity and applicable resolution in provenance. The dataset is scientific bathymetry and is not an official nautical chart. + +**Release evidence.** The frozen manifest MUST name the exact downloaded MGDS object(s), SHA-256 digest(s), source DOI, retrieval timestamp, spatial coverage used, and transformation activity that produced DataTiles cells. + +### 2. Bathymetric fallback — EMODnet Digital Bathymetry DTM 2024 + +**Role.** Fallback bathymetry **only where JammeGaia22 contains no finite measurement**. The fallback decision is a transformation rule and MUST be recorded; it must not overwrite finite JammeGaia22 values. + +**Bibliographic citation.** EMODnet Bathymetry Consortium (2024). *EMODnet Digital Bathymetry (DTM 2024)*. https://doi.org/10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1 + +**Persistent identifier.** DOI `10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1`. + +**Licence for this project profile.** CC BY 4.0; the frozen acquisition metadata/terms retained by the release remain authoritative for the exact downloaded object. + +**Required acknowledgement when it contributed.** `EMODnet Bathymetry Consortium DTM 2024, doi:10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1.` + +**Scientific limitations.** EMODnet DTM is a harmonised composite product assembled from heterogeneous source datasets. Resolution, survey age, source quality, vertical reference and uncertainty can vary spatially. It MUST NOT be represented as equivalent to the local JammeGaia22 multibeam measurements. The DataTiles source-coverage variable MUST retain which source supplied each output cell. + +### 3. Land topology / coastline — GSHHG 2.3.7 + +**Role.** Independent land/ocean topology and land mask. It is not bathymetry and MUST remain a separate fact from high-water-line products. + +**Citation.** Wessel, P., & Smith, W. H. F. (1996). A global, self-consistent, hierarchical, high-resolution shoreline database. *Journal of Geophysical Research*, 101(B4), 8741–8743. https://doi.org/10.1029/96JB00104 + +**Dataset/version.** GSHHG 2.3.7 (15 June 2017). + +**Licence.** GNU Lesser General Public License (as stated by NOAA/NCEI for GSHHG 2.3.7). Preserve the exact distributed notice with frozen source material. + +**Required acknowledgement.** `Land/coastline: GSHHG 2.3.7 (Wessel & Smith, 1996, doi:10.1029/96JB00104).` + +**Limitations.** GSHHG is a global hierarchical shoreline/topology database in WGS84 with multiple resolution levels. The production manifest MUST identify the resolution level actually used (the reference workflow uses full resolution where specified) and any clipping/generalisation performed. + +### 4. Optional high-water-line fact — S2Coast-2023 + +**Role.** Optional Sentinel-2-derived coastline/high-water-line evidence when explicitly enabled. It MUST remain distinguishable from GSHHG topology and from bathymetry. + +**Dataset citation.** Duan, Y., Sanchez-Azofeifa, A., Chen, C., Tian, B., Li, X., Sengupta, D., & Zhou, Y. *S2Coast-2023: The First Global 10-Meter Resolution Coastline Dataset Derived from Enhanced Sentinel-2 Composite Imagery Using Google Earth Engine*. Zenodo. https://doi.org/10.5281/zenodo.17092775 + +**Associated article.** Duan, Y. et al. (2026). S2Coast-2023: The first global 10-meter resolution coastline dataset derived from enhanced Sentinel-2 composite imagery using Google Earth Engine. *Remote Sensing of Environment*, 334, 115186. https://doi.org/10.1016/j.rse.2025.115186 + +**Licence.** CC BY 4.0 for the project source profile; retain the licence metadata of the exact frozen Zenodo object. + +**Required acknowledgement when enabled and contributing.** `S2Coast-2023 (Duan et al., doi:10.5281/zenodo.17092775), CC BY 4.0.` + +**Limitations.** Nominal 10 m global coastline derived from annual Sentinel-2 imagery. It is a remotely sensed coastline fact with its own temporal/reference assumptions; it is not interchangeable with GSHHG topology or a hydrographic chart coastline. + +### 5. Navigation and land context — OpenStreetMap + +**Role.** Stored navigation/land contextual features actually obtained from OpenStreetMap, including the demo's stored seamark/context vectors where present. + +**Attribution.** `© OpenStreetMap contributors`. + +**Licence.** Open Data Commons Open Database License (ODbL) 1.0. + +**Licence/copyright page.** https://www.openstreetmap.org/copyright + +**Required acknowledgement when OSM data contributed.** `Context: © OpenStreetMap contributors, ODbL 1.0.` + +**Licence note.** Publicly used produced works must provide attribution and make users aware that OpenStreetMap data are available under the ODbL. Distribution of a derivative database may trigger ODbL share-alike obligations. Release engineering MUST evaluate the actual distributed database/product rather than assuming that a software licence controls OSM-derived data. + +## Required short-form map credit + +For the current production build, when all named sources actually contributed, use: + +> Bathymetry: Foglini, Tonielli & Rovere (2024), JammeGaia22/MGDS, doi:10.60521/331667; EMODnet Bathymetry Consortium DTM 2024, doi:10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1. Land/coastline: GSHHG 2.3.7 and S2Coast-2023. Context: © OpenStreetMap contributors, ODbL 1.0. Not for navigation. + +The renderer/release process MUST remove a source from this credit when the run manifest proves that it contributed nothing. In particular, S2Coast-2023 must not be named when disabled or non-contributing, and EMODnet must not be named when no fallback cells were used. + +## Sources that MUST NOT be credited without evidence of use + +Do **not** list GMRT, GEBCO, EMODnet thematic products, ISPRA, or any other candidate/reference source as a contributor unless the specific frozen run manifest proves that its data contributed cells or features to the released product. A bibliography may discuss related datasets as background, but that is distinct from a data acknowledgement. + +Citation is source-specific evidence, not generic project boilerplate. + +## Frozen-manifest requirements + +Every production release SHOULD archive a manifest under the release evidence tree (for the reference demo, under `demo/from-gaeta-to-maratea/dist/` or the immutable evidence bundle produced by the build). At minimum, each source record MUST contain: + +- stable source key and human title; +- source role (`primary_bathymetry`, `fallback_bathymetry`, `land_topology`, `high_water_line`, `context`, etc.); +- PID/DOI and canonical landing URI where available; +- exact acquired object URI without credentials; +- retrieval timestamp; +- SHA-256 or the format-specific immutable-store identity defined by the DataTiles source profile; +- media/format and source version; +- licence identifier, licence URI, attribution text and notices; +- spatial/temporal coverage and resolution relevant to the run; +- transformation activity identifier; +- number/extent of output cells or features contributed; and +- an explicit `contributed: true|false` outcome determined by the build. + +The manifest itself MUST be checksummed and referenced by the generated DataTiles provenance graph. A source with `contributed: false` remains useful acquisition evidence but MUST NOT appear in contributor acknowledgements. + +## Machine-readable provenance and rights + +The DataTiles object and release package MUST preserve, for every contributing source: + +- a provenance entity carrying its PID/URI and cryptographic identity; +- a source-rights record separate from the generated dataset and metadata rights; +- the generating activity and its parameters; +- `used` / `wasDerivedFrom` relationships connecting output variables/tiles to inputs; +- source-coverage evidence for conditional fusion such as JammeGaia22 → EMODnet fallback; +- creator/provider attribution and citation metadata; and +- the generated object's own persistent identifier when published. + +Human-visible credits and machine-readable provenance are complementary; neither substitutes for the other. + +## FAIR release checklist + +Before a scholarly or public demo release, verify all of the following: + +- [ ] Every contributing source has a PID or stable URI and immutable object identity. +- [ ] Every contributing source has explicit licence/rights metadata and required attribution. +- [ ] No licence is inferred merely from successful download/access. +- [ ] Source and output licences are represented separately. +- [ ] The frozen manifest records `contributed` status and quantitative contribution evidence. +- [ ] Conditional fallback/fusion rules are explicit and reproducible. +- [ ] CF/controlled semantic names, units, CRS and vertical datum are retained where applicable. +- [ ] Machine-readable provenance links each derived product to the exact source objects and transformation activities. +- [ ] Human-visible acknowledgements are generated/checked against the frozen manifest. +- [ ] Non-contributing candidate sources are absent from contributor acknowledgements. +- [ ] DataCite/citation metadata includes appropriate related identifiers for contributing datasets. +- [ ] The release archive contains the manifest, provenance, rights, checksums, citation register and software/environment identity. +- [ ] `Not for navigation` is visible on the demo/map artifacts. +- [ ] Repository software licensing and data licensing are not conflated. + +## Verification sources for this register + +The citations and licence statements above should be periodically rechecked against the authoritative provider records before publication. At the time this register was prepared, the controlling public records were MGDS DOI `10.60521/331667`, the EMODnet Bathymetry DTM 2024 citation page, NOAA/NCEI GSHHG 2.3.7 documentation, Zenodo DOI `10.5281/zenodo.17092775`, and the OpenStreetMap copyright/licensing page. + +## Optional signed-release checklist + +When the release policy requires cryptographic integrity, sign only after the frozen run manifest has established actual source contribution and the visible acknowledgements have been derived from it. Archive the DataTiles object, detached signature envelope, trusted public key/certificate reference, and any external timestamp/transparency evidence. A signature never licenses a source and never changes which sources must be cited. + +## Commercial products remain source-specific + +Selling or encrypting a product does not change source attribution. The frozen contribution manifest remains authoritative: every source that actually contributed cells/features must be cited according to its licence, and a source must not be listed merely because it was a candidate. Commercialization must be blocked when a contributing source's terms do not permit the intended use. diff --git a/docs/digital-signatures.md b/docs/digital-signatures.md new file mode 100644 index 0000000..76ad9c8 --- /dev/null +++ b/docs/digital-signatures.md @@ -0,0 +1,119 @@ +# Cryptographic integrity and digital signatures + +DataTiles supports **optional** digital signatures as an integrity and authenticity layer. Signatures complement, but do not replace, checksums, provenance, persistent identifiers, licences, repository preservation, or scientific validation. + +## Threat model and claims + +A cryptographic hash answers: *are these logical DataTiles contents identical to the contents that produced this digest?* A digital signature additionally answers: *was that digest signed by the holder of a particular private key?* Neither statement, by itself, establishes who the human or institution behind the key is. Trust requires an independently authenticated public key, certificate, institutional key registry, Sigstore identity, or another trust policy. + +The native DataTiles profile signs a canonical **logical SQLite manifest**, not raw database bytes. This deliberately survives SQLite page layout, VACUUM, journaling, and other byte-level storage changes that do not alter the represented DataTiles content. It detects changes to signed tables, values, schema definitions, views, and triggers. The two integrity/signature tables are excluded to avoid circular self-signing. + +## Native profile + +- manifest profile: `DataTiles-Integrity-Manifest-1` +- canonicalization: `datatiles-sqlite-logical-v1` +- digest: SHA-256 +- native signature: Ed25519 +- signature envelope: `DataTiles-Ed25519-Signature-1` +- payload media type: `application/vnd.openfairwind.datatiles.integrity-manifest.v1+json` + +Ed25519 is specified by RFC 8032 and is included by NIST FIPS 186-5. The native signer is an optional dependency so the dependency-free DataTiles core remains usable without cryptography packages. + +## What is signed + +The manifest records schema revision and, for every non-integrity user table, a normalized schema digest, row count, and deterministic digest of the complete row multiset. Views and triggers are also schema-hashed. Each SQLite value is type-tagged before hashing; REAL values are represented by their IEEE-754 binary64 bit pattern and BLOB values by their exact bytes. + +Rows are independently SHA-256 hashed and externally sorted in bounded chunks before the table digest is produced. Consequently database row order is irrelevant, duplicate multiplicity is retained, and large tile tables do not require all row hashes in memory. + +`datatiles_integrity_manifests` and `datatiles_signatures` are excluded from the signed domain. Adding or removing signatures therefore does not invalidate the scientific object they attest. + +## Key generation and signing + +Install the optional cryptographic support: + +```bash +python -m pip install -e '.[integrity]' +``` + +Generate an Ed25519 key pair: + +```bash +datatiles-integrity generate-key \ + --private-key release-signing-key.pem \ + --public-key release-signing-key.pub.pem +``` + +The private key is PKCS#8 PEM and should be protected as a secret. The tool attempts to create it with owner-only permissions. Do not commit private keys to Git, release archives, containers, notebooks, or DataTiles files. + +Sign a final container and also emit a detached envelope: + +```bash +datatiles-integrity sign product.datatiles \ + --private-key release-signing-key.pem \ + --signer 'Ocean Data Laboratory' \ + --detached product.datatiles.sig.json +``` + +The signature may be stored inside the DataTiles object, emitted as a detached JSON envelope, or both. Detached signatures are useful for read-only repositories and archival systems. + +## Verification and trust + +Cryptographic verification with only the public key embedded in a signature proves self-consistency, **not signer trust**. A scholarly verification should supply a public key obtained independently: + +```bash +datatiles-integrity verify product.datatiles \ + --detached product.datatiles.sig.json \ + --public-key trusted-release-key.pub.pem +``` + +For automated release gates, pin the expected key identifier too: + +```bash +datatiles-integrity verify product.datatiles \ + --signature-id urn:uuid:... \ + --public-key trusted-release-key.pub.pem \ + --require-key-id sha256:... +``` + +Verification reports separately: + +- cryptographic validity of the signature; +- whether a trusted external key was supplied; +- whether the current DataTiles logical contents match the signed manifest; +- signer/key metadata and the signed root digest. + +Software MUST NOT convert “signature valid using its embedded key” into “trusted publisher”. + +## Provenance integration + +A signature can reference a `datatiles_provenance_agents` record through `signer_agent_id`. The agent identity should use an institutional URI, ORCID, ROR, or another persistent identifier when appropriate. The cryptographic key identifier and the scholarly agent identifier are separate facts and MUST NOT be conflated. + +Signing is a provenance activity performed **after** the final scientific build, rights evaluation, citation resolution, and release metadata freeze. NetCDF, GRIB2, and Zarr import utilities therefore do not auto-sign intermediate outputs. Release automation should sign only after all intended mutations have completed. + +## Sigstore and in-toto interoperability + +The native profile is offline-first. Publication pipelines MAY additionally publish a Sigstore bundle or an in-toto/DSSE attestation referencing the DataTiles manifest root. Such external evidence can provide certificate identity, transparency-log inclusion, and/or trusted timestamps. It must be recorded as verification material or publication evidence; it does not change the native DataTiles manifest digest. + +A Sigstore bundle should be archived with the release rather than requiring future network access for basic DataTiles verification. Keyless signatures require identity-policy verification (issuer and subject), not merely mathematical signature checking. + +## FAIR and preservation + +Digital signatures strengthen integrity and provenance evidence, particularly reproducibility and chain-of-custody assertions, but they are not a FAIR principle by themselves and remain optional. `fair_report()` must never fail solely because a DataTiles object is unsigned. A release profile may establish a stricter institutional policy requiring a trusted signature. + +For long-term preservation, archive together: + +1. the DataTiles object; +2. detached signature envelope(s), if used; +3. trusted public key/certificate or durable reference to it; +4. any Sigstore bundle, RFC 3161 timestamp, or transparency evidence; +5. PID/DataCite metadata, source manifests, citations, rights, and provenance. + +Key rotation is expected. Historical signatures must remain verifiable with archived historical public keys. Revocation affects trust policy, not the immutable mathematical fact that a given key produced a signature. + +## Non-goals + +A valid signature does not certify hydrographic authority, navigation safety, correctness of a model, correctness of source data, licence compatibility, or fitness for purpose. It proves integrity/authenticity only within the verifier's explicit trust policy. + +## Sign before protecting + +When both integrity signatures and DRM are used, finalize and sign the plaintext DataTiles object first, then create the protected `.dtpkg`. The DRM header binds to the plaintext SHA-256. This makes the signed scientific object stable and allows authorized recipients to verify the recovered DataTiles independently of the transport/access-control wrapper. diff --git a/docs/drm-and-commercial-licensing.md b/docs/drm-and-commercial-licensing.md new file mode 100644 index 0000000..bbf9de2 --- /dev/null +++ b/docs/drm-and-commercial-licensing.md @@ -0,0 +1,95 @@ +# Optional DRM and commercial licensing + +DataTiles supports an optional protected-distribution profile for institutions and companies that are legally entitled to sell proprietary cartographic or scientific products. DRM is a distribution control, not a substitute for copyright ownership, source licensing, attribution, provenance, FAIR metadata, or scientific validation. + +## Separation of concerns + +A DataTiles object remains an ordinary revision-7 SQLite scientific object. Publishers finalize its semantics, provenance, rights, citations, QA, and optional digital signatures first. Commercial distribution then wraps that immutable file in `DataTiles-Protected-Distribution-1` (`.dtpkg`). Authorized recipients decrypt the package back to the exact original bytes. + +The legal licence and technical entitlement are distinct. `datatiles_rights` and human-readable terms state the legal basis. W3C ODRL 2.2 can encode machine-readable permissions, prohibitions, constraints, and duties. The DRM licence token only conveys an issuer-signed access grant and a recipient-wrapped content-encryption key; it does not create rights the publisher does not already possess. + +## Cryptographic profile + +- content encryption: AES-256-GCM; +- package nonce: fresh random 96-bit nonce per protected package; +- recipient key agreement: X25519; +- key derivation: HKDF-SHA-256 with product-specific context; +- content-key wrapping: AES-256-GCM; +- licence issuer signature: Ed25519; +- product and key identifiers: SHA-256 based identifiers; +- public policy model: W3C ODRL 2.2. + +Private content keys and recipient private keys MUST NOT be embedded in the DataTiles object, package header, provenance graph, demo, playground, source repository, or licence token. Publisher content-key files are generated with restrictive filesystem permissions and should normally be moved into a KMS/HSM or equivalent institutional secret-management system. + +## Package and licence binding + +Each protected package carries a public, authenticated header containing the product identifier, edition, issuer, terms URI, optional licence-service URI, encryption profile, creation time, original DataTiles SHA-256, and original size. The header is authenticated as AES-GCM associated data. + +Each licence is bound to: + +- the exact `product_id`; +- the exact plaintext DataTiles SHA-256; +- a recipient X25519 public-key identifier; +- an issuer Ed25519 key identifier; +- optional validity start/end times; +- an explicit list of technical permissions; +- an optional ODRL 2.2 policy document. + +The content key is never sent in plaintext. It is wrapped to the recipient public key using an ephemeral X25519 key and HKDF-derived wrapping key. A copied licence therefore does not unlock the product for a different recipient key. + +## Threat and trust model + +This profile protects data at rest and in distribution and provides cryptographic evidence of issuer authorization. It cannot prevent an authorized user or compromised client from copying plaintext after decryption, photographing/rendering displayed information, or extracting data from a process with sufficient privileges. Stronger controls may use secure enclaves, platform key stores, HSM-backed device identities, remote attestation, or online authorization, but these are deployment profiles rather than requirements of the portable DataTiles format. + +The issuer public key must be authenticated independently. A mathematically valid licence signed by an unknown key is not evidence that a particular company or institution authorized access. + +## Commercial-law and upstream-rights rule + +A publisher MUST NOT commercialize a derivative merely because DataTiles can encrypt it. Every contributing source must permit the intended commercial use and redistribution, all attribution/share-alike/database-right obligations must be satisfied, and incompatible upstream terms must block the commercial release. DRM MUST NOT be used to remove, conceal, contradict, or technically frustrate licence notices that must remain available to recipients. + +## Workflow + +1. Build the DataTiles object from lawful sources. +2. Preserve source citations, provenance, source licences, and commercial-use evidence. +3. Register the commercial product and ODRL policy in the inner DataTiles object. +4. Complete scientific QA and FAIR checks. +5. Optionally sign the canonical DataTiles integrity manifest. +6. Freeze the release. +7. Protect the exact frozen file into `.dtpkg` and securely retain the generated content key. +8. Generate or obtain each customer's X25519 public key. +9. Issue an issuer-signed licence for that recipient and product. +10. Deliver the package plus licence; distribute the issuer public key through an authenticated channel. + +## CLI example + +```bash +python -m pip install -e '.[drm,integrity]' + +datatiles-drm protect product.datatiles product.dtpkg \ + --content-key-file product.cek.json \ + --product-id urn:example:charts:med-2026 \ + --issuer 'Example Hydrographic Publisher' \ + --terms-uri https://example.org/licence/med-2026 + +datatiles-drm generate-recipient-key \ + --private-key customer.key \ + --public-key customer.pub.pem + +datatiles-drm issue-license product.dtpkg \ + --content-key-file product.cek.json \ + --recipient-public-key customer.pub.pem \ + --issuer-private-key publisher-ed25519.pem \ + --issuer 'Example Hydrographic Publisher' \ + --recipient-id customer-123 \ + --permission read \ + --output customer-123.license.json + +datatiles-drm decrypt product.dtpkg product.datatiles \ + --license customer-123.license.json \ + --recipient-private-key customer.key \ + --issuer-public-key publisher-ed25519.pub.pem +``` + +## FAIR compatibility + +FAIR does not mean free-of-charge or unrestricted. Restricted/proprietary DataTiles can remain FAIR when metadata, identifiers, access conditions, provenance, vocabulary semantics, and licence terms are sufficiently machine-readable and persistent. Public discovery metadata should remain available even when data access requires purchase or authorization. DRM status must never be reported as a FAIR score, quality score, hydrographic certification, or navigation-safety claim. diff --git a/docs/fair-by-design.md b/docs/fair-by-design.md index 2bcb562..3af73ce 100644 --- a/docs/fair-by-design.md +++ b/docs/fair-by-design.md @@ -33,3 +33,9 @@ FAIR access does not imply frictionless or anonymous download. A provider may re ## FAIR maturity FAIRness should be reported as individual test outcomes, not a single opaque score. Passing container checks demonstrates machine actionability but cannot establish that an external PID is registered, a repository will retain metadata, or a scientific vocabulary is governed. Those repository-level assertions require publication evidence and periodic audit. + +## Academic publication profile + +The publication gate is strengthened by `fair-publication-profile.md`. DataTiles separates persistent identifiers, DataCite 4.7 citation metadata, W3C PROV-aligned lineage, SPDX rights, and external repository evidence. Dataset, metadata, and every upstream source MUST have distinct machine-actionable rights records. A public URL is never interpreted as permission. A strict publication report MUST fail when PID, catalogue registration, landing-page evidence, metadata-retention evidence, source rights, source lineage, or community semantics are absent. + +FAIR reports expose principle-level outcomes and repository-dependent assertions; they MUST NOT report an unexplained scalar FAIR score. diff --git a/docs/fair-publication-profile.md b/docs/fair-publication-profile.md new file mode 100644 index 0000000..34dd03f --- /dev/null +++ b/docs/fair-publication-profile.md @@ -0,0 +1,65 @@ +# DataTiles FAIR scientific publication profile + +## Status and normative intent + +This profile operationalizes the FAIR Guiding Principles for DataTiles scientific objects. It does **not** equate FAIR with open access, quality, truth, or fitness for a safety-critical purpose. The original FAIR principles require persistent identity and rich metadata (F1-F4), standardized access (A1-A2), formal shared semantics and qualified links (I1-I3), and explicit licence, provenance, and community standards (R1-R1.3). DataTiles therefore treats FAIRness as verifiable evidence, not a marketing score. + +Normative references are Wilkinson et al. (2016), W3C PROV-O/PROV-DM, DataCite Metadata Schema 4.7 (2026), SPDX 3.0.1 license expressions, CF Standard Names for scientific quantities, EPSG/OGC CRS identifiers, and domain vocabularies such as NERC NVS where applicable. + +## Publication invariants + +A DataTiles object intended for scholarly publication MUST satisfy all locally testable requirements below and MUST carry external publication evidence for repository-level assertions. + +1. **Identity.** The published object/revision has one explicit primary globally unique persistent identifier. DOI is RECOMMENDED for citable research datasets; Handle, ARK or another governed PID is acceptable. A mutable project URL is not a substitute for a versioned PID. +2. **Metadata.** Title, creators/contributors, publisher, publication year, spatial/temporal coverage, variables, units, CRS/datum, resolution, uncertainty/quality limitations, content profiles and version are machine readable. A DataCite 4.7-shaped export is available without claiming registration. +3. **Semantics.** Scientific variables use CF Standard Names when a suitable term exists, canonical units are declared, and external vocabulary mappings are retained. No local name is silently promoted to a CF term. +4. **Provenance.** Sources are immutable entities identified by URI/PID plus cryptographic digest where bytes are available. Transformations are activities with software identity/version, parameters and timestamps. Agents are explicit. Relations map to W3C PROV semantics. Tile lineage points to source/derived entities. +5. **Rights.** Dataset rights, metadata rights and every source dataset's rights are distinct records. SPDX expressions are used for machine-actionable licence identity; authoritative licence/terms URIs, attribution text, rights holder where known, and access-rights classification are retained. Public HTTP access MUST NOT be interpreted as permission to redistribute. +6. **Integrity and reproducibility.** Source locks, runtime locks, deterministic parameters, artifact manifests and checksums are retained. A checksum proves byte identity, not scientific validity. +7. **Accessibility.** The PID/landing page uses an open standardized protocol. Authentication/authorization may be required. Metadata persistence after data withdrawal is a repository obligation and requires evidence such as a preservation/retention policy. +8. **Indexing.** Catalogue/registry deposit is externally evidenced. The SQLite file cannot prove F4 by itself. +9. **Qualified links.** Source datasets, software, papers, previous/new versions, supplements and derived objects use typed related identifiers aligned with DataCite relation types. +10. **Limitations.** Known uncertainty, exclusions, transformations, resolution limits, licensing restrictions and safety boundaries remain visible in documentation and machine-readable metadata. + +## FAIR report semantics + +`fair_report(strict_publication=False)` evaluates facts the container can establish and labels repository-dependent checks as external. `strict_publication=True` makes missing catalogue registration, landing-page evidence and metadata-retention evidence publication blockers. A report MUST expose each principle-level result; implementations MUST NOT collapse FAIRness into an unexplained percentage. + +## Licensing model + +DataTiles distinguishes at least three legal layers: + +- **software licence**: governs the DataTiles implementation, not automatically the data; +- **dataset licence**: governs the generated scientific object; +- **source licences**: govern each upstream source and may impose attribution, share-alike, database-right or redistribution obligations. + +Metadata has its own rights record. CC0-1.0 is RECOMMENDED for newly authored machine-readable metadata where the publisher has authority to dedicate it, but DataTiles MUST NOT override third-party rights embedded in metadata or attribution requirements inherited from sources. + +Composite rights use valid SPDX expressions such as `CC-BY-4.0`, `ODbL-1.0`, or `(LicenseRef-A AND LicenseRef-B)`. Custom/non-SPDX terms use a stable `LicenseRef-*` plus the authoritative terms URI. The dependency-free core validates expression syntax; CI/publication SHOULD validate identifiers against a pinned SPDX License List release. + +## Provenance model + +The internal graph is PROV-aligned: + +- `datatiles_provenance_entities` -> `prov:Entity`; +- `datatiles_provenance_activities` -> `prov:Activity`; +- `datatiles_provenance_agents` -> `prov:Agent`; +- `wasGeneratedBy`, `used`, `wasDerivedFrom`, `wasAttributedTo`, `wasAssociatedWith`, `specializationOf` retain their W3C PROV meanings. + +A conversion SHOULD create a source entity, conversion activity, generated dataset entity, software agent and responsible agent, then connect them with `used`, `wasGeneratedBy`, `wasAssociatedWith`, `wasAttributedTo`, and `wasDerivedFrom`. Parameters MUST include resampling, CRS transformation, masking, classification/fusion rules and all values capable of changing scientific output. + +## DataCite alignment + +DataTiles 0.12 targets DataCite Metadata Schema 4.7 for publication export. The export is a candidate deposit record, not proof of DOI registration. `datatiles_related_identifiers` retains typed scholarly links. DataCite 4.7 added RAiD and SWHID identifier types and a general `Other` relation type; use more specific relation types whenever possible. + +## Minimum release evidence + +A scholarly release SHOULD archive: the `.datatiles` object; SHA-256 manifest; source lock; source-rights manifest; runtime/software lock; configuration; PROV export; DataCite metadata export; validation/FAIR report; citation metadata; quality-control results; and, where redistribution permits, immutable raw inputs. If raw bytes cannot legally be redistributed, preserve checksums, PIDs/request identity, rights metadata and a lawful reacquisition procedure instead. + +## Optional cryptographic release evidence + +A DataTiles release MAY include a revision-6 integrity manifest and one or more trusted digital signatures. Signature absence MUST NOT make the FAIR report fail: FAIRness and cryptographic authenticity are distinct properties. When signatures are used, archive the detached envelope, independently authenticated public key/certificate, signer PROV-agent binding, and any timestamp/transparency evidence with the publication package. + +## Restricted and commercial access + +FAIR does not require that every dataset be free or anonymously downloadable. A restricted/proprietary DataTiles release may use the revision-7 DRM profile while retaining public persistent identifiers, rich metadata, source-specific citation, provenance, interoperable semantics, and explicit access conditions. DRM presence or absence is not a FAIR criterion by itself. diff --git a/docs/images/demo/README.md b/docs/images/demo/README.md index bd08292..38f8276 100644 --- a/docs/images/demo/README.md +++ b/docs/images/demo/README.md @@ -5,7 +5,7 @@ These screenshots document an executed DataTiles demonstration; they are not sto ## Capture environment and inputs — 2026-08-28 - viewport: 1280 × 720 pixels; -- DataTiles version: 0.10.0; +- DataTiles version: 0.19.0; - publication extent: `12.85–15.71851° E, 39.99852–41.21408° N`; - generated container SHA-256: `b0f9732ce43a1b16729978b9ec8feabf82d513e1d8d71405893b1c828b4f55e2`; - source-lock SHA-256: `20effbe5c4612492c0a1ca3a1575216f3d2bce5e5178ef56d1c49c84616308b7`; diff --git a/docs/import-utilities.md b/docs/import-utilities.md new file mode 100644 index 0000000..c89e471 --- /dev/null +++ b/docs/import-utilities.md @@ -0,0 +1,81 @@ +# NetCDF, GRIB, and Zarr import utilities + +DataTiles treats NetCDF, GRIB, and Zarr as **source encodings**, not as the semantic identity of a quantity. The utilities under `utils/` translate those encodings into the DataTiles multidimensional model while retaining source identity, variable semantics, units, non-spatial coordinates, CRS and provenance. + +## Input identifiers + +A source argument may be a local filesystem path, a `file:` URI, or an HTTP(S) URL. URL sources are materialized to a temporary file because GRIB readers require seekable access and because byte-level SHA-256 provenance should be computed consistently. The converter records both the original source identifier and the checksum. It does not treat a downloaded temporary filename as scientific identity. + +## NetCDF mapping + +For each selected NetCDF data variable: + +- the local NetCDF variable name becomes the DataTiles variable token; +- a CF `standard_name`, when present, becomes the canonical semantic name under vocabulary `CF`; +- `units` becomes the registered canonical/unit declaration and DNT1 tile unit; +- `long_name` remains descriptive metadata, never a substitute for a controlled standard name; +- non-spatial dimensions such as `time`, `depth`, `pressure`, `ensemble` or `scenario` become typed DataTiles dimensions; +- one-dimensional latitude/longitude coordinates define the source rectilinear grid. + +If `standard_name` is absent, the converter registers the local token under vocabulary `source`. This preserves discoverability without inventing a CF Standard Name. + +## GRIB2 mapping + +GRIB is decoded through `cfgrib`/ecCodes. The decoded variable follows the same CF mapping as NetCDF. When cfgrib exposes GRIB metadata, the converter also records external identifiers: + +- `WMO-GRIB2`: `discipline.parameterCategory.parameterNumber`; +- `GRIB-shortName`: the producer/ecCodes short name; +- `GRIB2-variable`: the decoded xarray variable name. + +These identifiers are crosswalks. They do not replace the canonical CF semantic identity when a CF Standard Name is available. + +## Zarr mapping + +Zarr import uses xarray's Zarr backend and follows the same CF semantic mapping as NetCDF. Zarr format 2 and 3 are supported when the installed xarray/zarr stack supports them. Local directory stores receive a deterministic `zarr-tree-sha256-v1` digest over the complete object tree; remote Zarr stores require an authoritative `--source-sha256` for an immutable published store or snapshot. This distinction is normative: a multi-object store is not misrepresented as a single file. + +The importer supports `--group`, `--consolidated`, `--zarr-format`, and repeatable fsspec `--storage-option` settings. Storage-option values can contain credentials and therefore are never persisted; provenance records only the option names. Signed or credential-bearing access URLs require a separate stable `--provenance-uri`, preventing secrets from entering the evidence graph. See `zarr-source-profile.md` for the complete ingestion contract. + +## Spatial transformation + +The utility profile is deliberately conservative. It supports rectilinear 1-D latitude/longitude source grids. Target pixels are Web Mercator tile pixel centers and source values are selected using nearest-neighbour sampling. Data are stored as DNT1 numeric arrays and written through the XYZ interface; DataTiles persists the corresponding TMS row. + +This is an explicit algorithm, not a hidden portrayal step. The tile content schema records `resampling=nearest`, `grid=WebMercatorQuad`, tile size and source kind. Future higher-order or conservative remapping methods must declare different algorithm identifiers and parameters. + +The utilities reject rather than guess for unsupported curvilinear/2-D coordinates, antimeridian-crossing bboxes, and non-rectilinear grids. + +## Resource and safety bounds + +`--max-tiles` limits accidental expansion at inappropriate zooms. `--bbox` can constrain the import domain. URL access has a timeout and is streamed to disk while hashing instead of being accumulated in memory. DNT1 itself retains its normal element and header bounds. + +## Examples + +```bash +python utils/netcdf2datatiles.py input.nc output.datatiles \ + --variable sea_floor_depth_below_geoid --zoom 8 +``` + +```bash +python utils/grib2datatiles.py https://example.org/model.grib2 output.datatiles \ + --filter-by-keys typeOfLevel=surface --variable t2m --zoom 6 +``` + +```bash +python utils/zarr2datatiles.py ./ocean.zarr output.datatiles \ + --variable depth --zoom 7 +``` + +Validate semantic registration after import: + +```bash +datatiles validate output.datatiles --require-variable-semantics +``` + +For strict CF-table membership validation, additionally provide the pinned official CF XML table used by the publication workflow. + +## Signing imported products + +The NetCDF, GRIB2, and Zarr utilities intentionally do not sign during ingestion. Import output is normally intermediate scientific evidence that may still undergo semantic curation, fusion, portrayal preparation, citation resolution, QA, and rights review. Sign the final frozen DataTiles object with `datatiles-integrity` only after those steps. This avoids producing apparently authoritative signatures over incomplete intermediate products. + +## DRM is not an ingestion step + +NetCDF, GRIB2, and Zarr importers do not encrypt or issue commercial licences. Source ingestion must preserve upstream rights and provenance first. DRM is applied only to a finalized lawful release after scientific QA, citation/licence review, FAIR metadata completion, and optional integrity signing. diff --git a/docs/migrations/v4-to-v5.sql b/docs/migrations/v4-to-v5.sql new file mode 100644 index 0000000..227ed43 --- /dev/null +++ b/docs/migrations/v4-to-v5.sql @@ -0,0 +1,62 @@ +-- DataTiles schema revision 5: FAIR publication identity and rights. +-- Revision 5 makes rights machine-actionable and separates object identifiers, +-- related identifiers, and publication evidence from generic MBTiles metadata. + +CREATE TABLE datatiles_identifiers ( + identifier_id INTEGER PRIMARY KEY, + scheme TEXT NOT NULL, + identifier TEXT NOT NULL, + uri TEXT, + is_primary INTEGER NOT NULL DEFAULT 0 CHECK(is_primary IN (0,1)), + UNIQUE(scheme,identifier) +); +CREATE UNIQUE INDEX datatiles_one_primary_identifier ON datatiles_identifiers(is_primary) WHERE is_primary=1; + +CREATE TABLE datatiles_related_identifiers ( + scheme TEXT NOT NULL, + identifier TEXT NOT NULL, + relation_type TEXT NOT NULL, + uri TEXT, + resource_type TEXT, + relation_information TEXT, + PRIMARY KEY(scheme,identifier,relation_type) +) WITHOUT ROWID; + +CREATE TABLE datatiles_rights ( + rights_id INTEGER PRIMARY KEY, + scope TEXT NOT NULL CHECK(scope IN ('dataset','metadata','source','portrayal','software','other')), + license_expression TEXT NOT NULL, + license_uri TEXT, + rights_holder TEXT, + rights_holder_uri TEXT, + attribution_text TEXT, + copyright_notice TEXT, + access_rights TEXT NOT NULL DEFAULT 'open' CHECK(access_rights IN ('open','embargoed','restricted','closed')), + source_entity_id TEXT REFERENCES datatiles_provenance_entities(entity_id) ON DELETE CASCADE, + applies_to TEXT, + UNIQUE(scope,source_entity_id,license_expression,applies_to) +); +CREATE INDEX datatiles_rights_scope ON datatiles_rights(scope,access_rights); + +CREATE TABLE datatiles_fair_agents ( + agent_id TEXT NOT NULL REFERENCES datatiles_provenance_agents(agent_id) ON DELETE CASCADE, + role TEXT NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(agent_id,role) +) WITHOUT ROWID; + +CREATE TABLE datatiles_publication_evidence ( + evidence_type TEXT NOT NULL, + uri TEXT NOT NULL, + checked_at TEXT, + checksum_algorithm TEXT, + checksum TEXT, + notes TEXT, + PRIMARY KEY(evidence_type,uri) +) WITHOUT ROWID; + +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:fair_profile','FAIR-Guiding-Principles-2016'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:provenance_profile','W3C-PROV-2013'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:metadata_profile','DataCite-4.7'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:license_profile','SPDX-3.0.1-expression'); +PRAGMA user_version = 5; diff --git a/docs/migrations/v5-to-v6.sql b/docs/migrations/v5-to-v6.sql new file mode 100644 index 0000000..02850c8 --- /dev/null +++ b/docs/migrations/v5-to-v6.sql @@ -0,0 +1,29 @@ +CREATE TABLE datatiles_integrity_manifests ( + manifest_id TEXT PRIMARY KEY, + profile TEXT NOT NULL, + canonicalization TEXT NOT NULL, + hash_algorithm TEXT NOT NULL CHECK(hash_algorithm = 'sha256'), + root_sha256 TEXT NOT NULL UNIQUE CHECK(length(root_sha256) = 64), + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL +) WITHOUT ROWID; + +CREATE TABLE datatiles_signatures ( + signature_id TEXT PRIMARY KEY, + manifest_id TEXT NOT NULL REFERENCES datatiles_integrity_manifests(manifest_id) ON DELETE CASCADE, + signature_scheme TEXT NOT NULL, + signature_encoding TEXT NOT NULL, + signature BLOB NOT NULL, + key_id TEXT NOT NULL, + public_key BLOB, + signer_agent_id TEXT REFERENCES datatiles_provenance_agents(agent_id) ON DELETE SET NULL, + signed_at TEXT NOT NULL, + verification_material_json TEXT, + UNIQUE(manifest_id, signature_scheme, key_id, signature) +) WITHOUT ROWID; +CREATE INDEX datatiles_signatures_manifest ON datatiles_signatures(manifest_id); +CREATE INDEX datatiles_signatures_key ON datatiles_signatures(key_id); + +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:integrity_profile','DataTiles-Integrity-Manifest-1'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:signature_profile','DataTiles-Ed25519-Signature-1'); +PRAGMA user_version = 6; diff --git a/docs/migrations/v6-to-v7.sql b/docs/migrations/v6-to-v7.sql new file mode 100644 index 0000000..5f24756 --- /dev/null +++ b/docs/migrations/v6-to-v7.sql @@ -0,0 +1,24 @@ +CREATE TABLE datatiles_commercial_products ( + product_id TEXT PRIMARY KEY, + edition TEXT, + issuer TEXT NOT NULL, + issuer_uri TEXT, + terms_uri TEXT NOT NULL, + license_service_uri TEXT, + protection_profile TEXT NOT NULL DEFAULT 'DataTiles-Protected-Distribution-1', + created_at TEXT NOT NULL, + metadata_json TEXT +) WITHOUT ROWID; + +CREATE TABLE datatiles_drm_policies ( + product_id TEXT NOT NULL REFERENCES datatiles_commercial_products(product_id) ON DELETE CASCADE, + policy_id TEXT NOT NULL, + policy_profile TEXT NOT NULL DEFAULT 'W3C-ODRL-2.2', + policy_json TEXT NOT NULL, + PRIMARY KEY(product_id, policy_id) +) WITHOUT ROWID; +CREATE INDEX datatiles_drm_policy_product ON datatiles_drm_policies(product_id); + +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:drm_profile','DataTiles-Protected-Distribution-1'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:rights_policy_profile','W3C-ODRL-2.2'); +PRAGMA user_version = 7; diff --git a/docs/migrations/v7-to-v8.sql b/docs/migrations/v7-to-v8.sql new file mode 100644 index 0000000..2d8386a --- /dev/null +++ b/docs/migrations/v7-to-v8.sql @@ -0,0 +1,16 @@ +CREATE TABLE datatiles_release ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + product_id TEXT NOT NULL, + version TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence >= 1), + released_at TEXT NOT NULL, + previous_version TEXT, + previous_identifier TEXT, + release_notes_uri TEXT, + update_uri TEXT, + CHECK(product_id <> '' AND trim(product_id) = product_id), + CHECK(version <> '' AND trim(version) = version) +); +CREATE UNIQUE INDEX datatiles_release_product_version ON datatiles_release(product_id, version); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:versioning_profile','DataTiles-Release-Versioning-1'); +PRAGMA user_version = 8; diff --git a/docs/playground.md b/docs/playground.md index 1d39912..bbb2125 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -63,3 +63,15 @@ The rendering pipeline is therefore `DNT1 arrays → sampled numeric surface → *Figure 9. Executed `0 < depth < 200` m and sand-or-mud query with 820 highlighted analysis cells. The result is a request-time GeoJSON derivation and not a stored portrayal.* For development, build the demo, run `datatiles-serve work/gaeta-to-maratea.datatiles --port 8080`, and visit `http://127.0.0.1:8080/playground`. The OpenLayers dependency is version-pinned. A production deployment should self-host the pinned asset, apply CSP and request rate limits, cache derived responses by canonical query plus dataset checksum, and execute expensive processing through a bounded worker service. + +## FAIR evidence panel + +The live playground now exposes the container's principle-level FAIR report and rights-record count, with machine-readable `/fair`, `/rights`, `/provenance`, and `/datacite` resources. This is intentionally adjacent to the scientific visualization: a user must be able to inspect lineage and reuse conditions without leaving the object. The panel never converts FAIRness into a safety or scientific-validity badge. Static exports embed the same rights, PROV, DataCite candidate metadata, primary identifier and FAIR report in `data/manifest.json`. + +## Cryptographic integrity evidence + +The playground may display the number of recorded signature records via `/integrity`. This is disclosure, not verification or trust. Full manifest computation is available explicitly at `/integrity/manifest` because it can require reading every scientific tile. Private-key signing is intentionally absent from the HTTP service. + +## Commercial/DRM disclosure + +An authorized plaintext playground may expose non-secret commercial product metadata and ODRL policy. A public storefront/demo should disclose product identity, issuer, terms URI, access status, and required source acknowledgements without exposing protected scientific payloads or key material. DRM status is not a safety or quality badge. diff --git a/docs/provenance.md b/docs/provenance.md new file mode 100644 index 0000000..a04531b --- /dev/null +++ b/docs/provenance.md @@ -0,0 +1,17 @@ +# Scientific provenance + +DataTiles provenance follows the W3C PROV entity-activity-agent model and keeps tile-level lineage. Provenance must answer: **what bytes or observations were used, who/what acted, what transformation occurred, with which parameters/software, and what was generated?** + +Source entities SHOULD have stable PID/URI, release/version, SHA-256 (or stronger approved digest), acquisition time, request identity and source-kind attributes. Activities SHOULD record start/end timestamps, software package/version or SWHID, configuration digest, resampling/interpolation, CRS operations, masks, quality filters and deterministic/random seeds. Agents SHOULD use ORCID for persons and ROR or another governed organizational identifier where available. + +The provenance graph must distinguish evidence from assertion. A checksum verifies bytes. A citation identifies intellectual origin. A licence states terms. A quality report supports scientific validity. None substitutes for the others. + +`prov_json()` exports the graph with explicit W3C PROV namespace mappings. For publication, validate the graph for dangling identifiers, source coverage, generated-entity links and activity-agent associations, then archive the export beside the artifact manifest. + +## Signing as a provenance activity + +Signing occurs only after the final reproducible build is frozen. The signer key identifier and scholarly PROV agent are separate identifiers. A valid signature may establish that a key attested a particular immutable manifest; attribution to a person or organization requires an external trust binding. Key rotation and revocation policy belong to the publishing institution and must not rewrite historical provenance. + +## DRM provenance boundary + +The commercial product ID, edition, issuer, terms URI, and public ODRL policy may be provenance-relevant and are part of the signed inner DataTiles object. Content keys, recipient private keys, licence-signing private keys, payment data, bearer tokens, and other secrets are operational security material and MUST NOT enter the scholarly provenance graph. diff --git a/docs/semantic-variables.md b/docs/semantic-variables.md new file mode 100644 index 0000000..abf8d25 --- /dev/null +++ b/docs/semantic-variables.md @@ -0,0 +1,85 @@ +# Semantic variable interoperability profile + +## Status + +Proposed additive extension for DataTiles schema revision 4. + +## Goal + +A DataTiles consumer must be able to discover a physical quantity without knowing a producer-specific coordinate value such as `depth`, `DEPTH`, `h`, or `bathymetry`. + +The `variable` scientific dimension remains part of tile addressing. A separate semantic registry maps each local variable token to a controlled semantic identity. + +## Canonical vocabulary + +For geophysical and environmental physical quantities, DataTiles uses **CF Standard Names** as the preferred canonical semantic vocabulary. The registry records the vocabulary and version explicitly; examples in this profile target CF Standard Name Table v94 (2026-06-09). + +A registered CF variable SHOULD include the CF canonical unit. Actual numeric payload units may be physically equivalent rather than textually identical. Core DataTiles does not claim UDUNITS dimensional-equivalence validation because the project keeps its core dependency-free. + +DataTiles MUST NOT invent a CF standard name. If no suitable CF name exists, the producer should use an explicitly named external vocabulary or propose a new CF name through the CF process. + +## External identifiers + +Additional identifiers are first-class crosswalks, not replacements for the canonical semantic identity. Typical schemes include: + +- `WMO-GRIB2` +- `SeaDataNet-P01` +- `NERC-NVS` +- `GCMD` +- provider-specific namespaces + +Each mapping stores a scheme, identifier, optional scheme version, and optional persistent URI. + +## Example: bathymetry + +Local tile coordinate: + +```text +variable=bathymetry +``` + +Semantic registry entry: + +```json +{ + "name": "bathymetry", + "standard_name": "sea_floor_depth_below_geoid", + "standard_name_vocabulary": "CF", + "standard_name_vocabulary_version": "94", + "canonical_unit": "m", + "long_name": "Sea floor depth below geoid" +} +``` + +CF distinguishes this quantity from sea-floor depth below mean sea level, a geopotential datum, a reference ellipsoid, or the instantaneous sea surface. The datum/reference surface therefore remains semantically significant and must not be collapsed into a generic `depth` concept. + +## Enforcement policy + +`metadata['datatiles:variable_semantics']` is one of: + +- `required`: every point value of a text `variable` dimension must have a registry entry before tiles using that value may be written; validation also rejects unregistered values. +- `recommended`: legacy-compatible mode. Unregistered values are permitted, but semantic validation may report them when strict validation is requested. + +New schema-revision-4 containers SHOULD default to `required`. Migration from revision 3 SHOULD set `recommended` so old containers remain readable and writable until curated. + +## Offline CF validation + +The core implementation must not download vocabularies implicitly. A strict validator accepts a local, pinned official CF standard-name-table XML snapshot. It checks that the standard name exists and, when `canonical_unit` is supplied, that it matches the CF table's canonical-unit string. + +This exact-string canonical-unit check is intentionally narrower than UDUNITS equivalence. DataTiles must not claim full CF unit conformance without a real UDUNITS-compatible implementation. + +## Discovery + +Consumers should be able to query by semantic identity: + +```text +standard_name=sea_floor_depth_below_geoid +``` + +and receive the matching local variable(s), external identifiers, and coordinate-set IDs. The HTTP service should expose the same registry under: + +```text +/collections/{collectionId}/variables +``` + +This enables independent software to discover compatible DataTiles datasets without prior knowledge of producer-local variable names. diff --git a/docs/specification-revision-5-addendum.md b/docs/specification-revision-5-addendum.md new file mode 100644 index 0000000..e45380d --- /dev/null +++ b/docs/specification-revision-5-addendum.md @@ -0,0 +1,7 @@ +# DataTiles schema revision 5 — semantic, FAIR, rights, and source-ingestion addendum + +This addendum accompanies the extension bundle and records normative changes layered on the repository's DataTiles 1.0-draft revision-3 specification. The application script migrates schema revisions 3→4→5 and updates the repository specification with the Zarr ingestion profile. Until these changes are merged into one consolidated upstream specification, a conformance statement for this extension MUST cite both the base DataTiles 1.0-draft specification and this revision-5 addendum. + +Revision 4 adds the canonical semantic-variable registry and external identifier crosswalks. Revision 5 adds persistent identifiers, related identifiers, structured rights/licensing, FAIR publication evidence, DataCite-shaped citation metadata, and strengthened W3C PROV-aligned publication records. The scientific import profile comprises NetCDF, GRIB2, and Zarr source encodings. + +For Zarr specifically, the normative source contract is `zarr-source-profile.md`. Implementations MUST distinguish a multi-object store digest from a single-file SHA-256, MUST bind remote stores to immutable/versioned evidence, MUST preserve scientific semantics and source/output rights, MUST record the conversion parameters needed to reproduce tiling, and MUST NOT persist credentials or infer reuse permission from network accessibility. diff --git a/docs/specification-revision-6-addendum.md b/docs/specification-revision-6-addendum.md new file mode 100644 index 0000000..3715531 --- /dev/null +++ b/docs/specification-revision-6-addendum.md @@ -0,0 +1,45 @@ +# DataTiles 1.0-draft — schema revision 6 addendum: cryptographic integrity + +This addendum extends schema revision 5 with optional integrity manifests and digital signatures. All revision-5 scientific, semantic, FAIR, provenance, licensing, and MBTiles-compatibility requirements remain unchanged. + +## New tables + +`datatiles_integrity_manifests` stores canonical logical manifests. Each manifest declares its profile, canonicalization, hash algorithm, SHA-256 root, canonical JSON representation, and creation time. + +`datatiles_signatures` stores one or more signatures over a recorded manifest. Native signatures use Ed25519 and may embed the raw public key required for self-contained mathematical verification. `signer_agent_id` MAY link the signature to a PROV agent. `verification_material_json` MAY retain additional certificate, transparency-log, timestamp, or external trust material. + +The signature tables are intentionally outside the canonical signed domain. Signature insertion/removal MUST NOT change the manifest root of otherwise identical scientific content. + +## Canonical integrity manifest + +Conforming implementations of `DataTiles-Integrity-Manifest-1` MUST: + +1. hash the logical SQLite object rather than raw database bytes; +2. include the DataTiles schema revision; +3. include every user table except `datatiles_integrity_manifests` and `datatiles_signatures`; +4. include normalized schema definitions for those tables and definitions for user views/triggers; +5. preserve SQLite value types and exact BLOB bytes in row canonicalization; +6. preserve the IEEE-754 binary64 representation of REAL values; +7. make table row order irrelevant while preserving duplicate multiplicity; +8. use SHA-256 for row, table, schema, and root digests; +9. serialize manifest JSON with UTF-8, lexicographically sorted object keys, no insignificant whitespace, and no NaN/Infinity JSON numbers. + +The normative implementation profile is documented in `digital-signatures.md`. + +## Signature profile + +`DataTiles-Ed25519-Signature-1` signs the complete canonical manifest JSON, including its verified `root_sha256`. The signature record MUST identify its algorithm and key identifier. The key identifier is `sha256:` followed by the lowercase SHA-256 digest of the 32-byte raw Ed25519 public key. + +Verification MUST distinguish mathematical validity from trust. An embedded public key is sufficient for cryptographic verification but is not, by itself, evidence that the key belongs to a claimed person or institution. + +## Migration + +Revision 5 containers migrate to revision 6 by adding the two tables and profile metadata. Existing content remains unsigned. Migration MUST NOT invent signatures, signer identities, timestamps, or trust assertions. + +## HTTP and playground + +Servers MAY expose read-only integrity/signature metadata. They MUST NOT expose private keys and SHOULD NOT provide remote signing endpoints. Because full manifest recomputation may require reading every tile, interactive services SHOULD avoid implicit full verification on every request; verification belongs in release/validation workflows or explicitly requested operations. + +## FAIR relationship + +Digital signatures are optional enhanced evidence. Unsigned DataTiles can still satisfy the FAIR publication profile. A FAIR report MAY disclose signature presence, but MUST NOT conflate a signature with FAIRness, scientific validity, legal compliance, or safety certification. diff --git a/docs/specification-revision-7-addendum.md b/docs/specification-revision-7-addendum.md new file mode 100644 index 0000000..40e90e8 --- /dev/null +++ b/docs/specification-revision-7-addendum.md @@ -0,0 +1,36 @@ +# DataTiles 1.0-draft — schema revision 7 addendum + +## Commercial product metadata + +Revision 7 adds `datatiles_commercial_products` and `datatiles_drm_policies`. These tables are part of the canonical scientific object and therefore are covered by `DataTiles-Integrity-Manifest-1` when the object is signed. + +`datatiles_commercial_products` identifies a commercial edition, publisher/issuer, human-readable terms URI, optional licence-service URI, and protection profile. `datatiles_drm_policies` stores machine-readable policy documents; the default policy profile is W3C ODRL 2.2. + +These tables MUST NOT contain plaintext content-encryption keys, customer private keys, passwords, payment credentials, access tokens, or other secrets. + +## Protected distribution + +`DataTiles-Protected-Distribution-1` is an optional outer distribution format and does not replace the SQLite DataTiles format. A `.dtpkg` contains a public canonical JSON header plus AES-256-GCM ciphertext of one complete finalized DataTiles file and the GCM authentication tag. The public header is authenticated additional data and binds ciphertext to the product identity and plaintext SHA-256. + +Authorized decryption MUST reproduce the exact original DataTiles bytes and MUST verify the stored SHA-256 before releasing the result as valid. + +## Licence grants + +`DataTiles-Commercial-License-1` is an issuer-signed JSON envelope. The native portable profile uses Ed25519 signatures and X25519 recipient keys. The content key is wrapped with a product-bound HKDF-SHA-256 derived key and AES-256-GCM. + +Implementations MUST distinguish: + +1. cryptographic validity of the issuer signature; +2. external trust in the issuer public key; +3. recipient-key binding; +4. validity-period enforcement; +5. product/hash binding; +6. legal rights expressed by the dataset licence and ODRL/human-readable terms. + +A valid DRM licence MUST NOT be interpreted as evidence that upstream data may lawfully be sold or redistributed. + +## Services + +The standard read-only DataTiles HTTP service MAY expose non-secret commercial product metadata and ODRL policies from an authorized plaintext DataTiles object. It MUST NOT expose publisher content keys, recipient private keys, licence signing keys, or a standard endpoint that bypasses protected-distribution authorization. + +Online purchase, payment, account management, revocation, floating licences, device activation, and remote attestation are deployment-specific services outside the normative portable profile. diff --git a/docs/specification-revision-8-addendum.md b/docs/specification-revision-8-addendum.md new file mode 100644 index 0000000..b1e119f --- /dev/null +++ b/docs/specification-revision-8-addendum.md @@ -0,0 +1,21 @@ +# DataTiles schema revision 8 addendum: release versioning + +## Status + +This addendum defines the normative `DataTiles-Release-Versioning-1` profile. A DataTiles file represents one immutable scientific release. Versioning identifies successive releases of the same logical product; it does not mutate an old signed release in place. + +## Stable product identity and release identity + +A revision-8 container MAY contain exactly one row in `datatiles_release`. When present, `product_id`, `version`, `sequence`, and `released_at` are REQUIRED. `product_id` MUST remain stable across releases of the same logical product and SHOULD be a persistent URI/URN/DOI concept identifier under publisher control. `version` is the human-facing release label and MAY use Semantic Versioning, calendar versioning, an institutional scheme, or another documented scheme. + +`sequence` is the normative ordering field. It MUST be a positive integer, MUST increase for each published successor of the same `product_id`, and MUST NOT be reused for a different release. Consumers MUST NOT infer ordering from lexical comparison of `version`. `released_at` MUST be an RFC 3339 timestamp. + +Optional `previous_version` and `previous_identifier` fields link the release to its predecessor. `release_notes_uri` SHOULD identify a human-readable changelog. `update_uri` MAY identify a catalogue/API endpoint where successors can be discovered. + +## Immutability + +A new scientific release MUST be represented by a new DataTiles object with a new checksum and, where signing is used, a new integrity manifest/signature. Replacing bytes underneath an already published version is non-conforming. Corrected products MUST increment `sequence` and use a new release label. + +## Provenance, citation, and Store update discovery + +Version metadata complements, but never replaces, provenance, persistent identifiers, rights, source citation, and integrity evidence. A citation SHOULD identify the exact release used. A Store MAY notify users who accessed an older sequence when a larger sequence for the same `product_id` is published. Such notification does not by itself grant entitlement to the new release. diff --git a/docs/specification.md b/docs/specification.md index bf898ea..8cc5402 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -1,4 +1,4 @@ -# DataTiles 1.0-draft specification (schema revision 3) +# DataTiles 1.0-draft specification (schema revision 8) ## Status, audience, and reading convention @@ -36,7 +36,7 @@ A partial implementation MUST NOT claim the class whose requirements it omits. - The file MUST be SQLite 3 and SHOULD use `.datatiles`. - `PRAGMA application_id` MUST equal hexadecimal `0x44415441` (`DATA`). -- `PRAGMA user_version` MUST equal `3`. +- `PRAGMA user_version` MUST equal `8`. - Foreign keys MUST be enabled by writers and validators. - Text is UTF-8. JSON is UTF-8 and MUST reject NaN and infinities. - The spatial tile matrix is Web Mercator; rows stored in the database use TMS orientation. @@ -380,3 +380,23 @@ An implementation MUST NOT guess payload type from BLOB signatures when a conten ## 18. Evolution Readers MUST reject unsupported schema revisions rather than infer them. A migration MUST be transactional, deterministic, documented, and preserve scientific identity. Existing columns MUST NOT be reinterpreted. Future revisions may define non-Gregorian calendars, content deduplication, tile checksums, dimension overviews, additional vector encodings, and further OGC API profiles. + + +## Zarr source-ingestion profile + +A conforming `zarr2datatiles` source utility MUST follow `zarr-source-profile.md`. Zarr is a multi-object N-dimensional store rather than a single-file checksum domain. Local directory stores MUST use the canonical `zarr-tree-sha256-v1` digest defined there; remote stores MUST be bound to an immutable store/snapshot by an authoritative checksum supplied by the publication workflow. The importer MUST preserve the source identifier, checksum algorithm/value, CF semantics when present, dimensions, units, declared resampling, source/output rights, and tile-level lineage. It MUST NOT persist backend credentials or infer data licensing from transport accessibility. Zarr format 2 and 3 MAY be accepted; any required format, group, consolidated-metadata policy, and non-secret storage-option keys MUST be recorded as conversion parameters. Scientific arrays MUST remain numeric DNT1 evidence rather than being silently converted to portrayal imagery. + + +## Cryptographic integrity profile + +Schema revision 6 adds optional canonical integrity manifests and digital signatures. Implementations claiming the native profile MUST follow `digital-signatures.md` and `specification-revision-6-addendum.md`. The signed subject is the canonical logical DataTiles object, not raw SQLite file bytes. The native signature algorithm is Ed25519 over `DataTiles-Integrity-Manifest-1`; SHA-256 remains the digest primitive. Signature metadata MUST distinguish cryptographic validity from trust in signer identity. Signature presence is optional and MUST NOT be presented as proof of FAIRness, scientific correctness, legal compliance, hydrographic authority, or navigation safety. Servers MUST NOT expose private-key signing endpoints as part of the standard DataTiles read service. + + +## Optional commercial protected distribution + +Schema revision 7 adds commercial-product and machine-readable policy metadata. Implementations supporting commercial protected distribution MUST follow `drm-and-commercial-licensing.md` and `specification-revision-7-addendum.md`. `DataTiles-Protected-Distribution-1` is an optional outer encrypted distribution package; after authorized decryption the payload MUST be the exact ordinary DataTiles SQLite object. W3C ODRL 2.2 is the default machine-readable rights-policy model. Technical DRM grants MUST NOT be interpreted as source relicensing, scientific certification, hydrographic authority, FAIR certification, or navigation safety. Secrets MUST remain outside the DataTiles metadata/provenance graph and standard read-only service. + + +## Release versioning + +Schema revision 8 defines `DataTiles-Release-Versioning-1`. A published DataTiles object MAY declare one `datatiles_release` record containing stable `product_id`, human `version`, monotonically increasing integer `sequence`, RFC 3339 `released_at`, and optional predecessor/release-notes/update links. Consumers MUST use `sequence`, not lexical `version`, to order releases. Published versions are immutable; a correction is a new DataTiles object with a larger sequence, new checksum, and new signature where signing is used. See `specification-revision-8-addendum.md`. diff --git a/docs/store-api.md b/docs/store-api.md new file mode 100644 index 0000000..3ce8d0b --- /dev/null +++ b/docs/store-api.md @@ -0,0 +1,114 @@ +# DataTiles Store API v1 + +The Store is API-first. Human HTML/PWA pages are convenience clients; third-party applications should integrate through `/api/v1`. API authorization resolves to the same SQLAlchemy `User -> Group -> Role -> Permission` graph used by browser sessions. + +## Authentication + +Obtain an opaque Bearer token: + +```http +POST /api/v1/auth/token +Content-Type: application/json + +{"username":"alice","password":"...","name":"desktop client","expires_in":2592000} +``` + +Use the returned token as: + +```http +Authorization: Bearer dts_... +``` + +The Store persists only a SHA-256 hash and short non-secret prefix, never the bearer secret itself. Tokens can be listed, created, and revoked through `/api/v1/auth/tokens`. Revocation or user deactivation stops future authorization. TLS is mandatory for production API use. + +## Agreement workflow + +Catalog metadata can be discovered without accepting a product licence when the user's role permits catalog view. Payload access is different: portrayal tiles and file download require a current acceptance for the exact release. + +```http +GET /api/v1/catalog/42/agreement +``` + +returns the file SHA-256, structured rights records, canonical licence fingerprint, safety agreement version/text and current acceptance status. + +Acceptance requires an explicit JSON body: + +```http +POST /api/v1/catalog/42/agreement/accept +Authorization: Bearer ... +Content-Type: application/json + +{"accept_license":true,"accept_safety":true} +``` + +The acceptance is invalid for access purposes if the DataTiles SHA-256, extracted rights fingerprint, or configured safety-agreement version changes. A gated endpoint returns HTTP `428 Precondition Required` with an `agreement_url` when a current acceptance is missing. + +## Catalog and search + +```text +GET /api/v1/catalog?q=sea_floor_depth_below_geoid +GET /api/v1/catalog/{id} +``` + +Search uses metadata automatically extracted from the indexed DataTiles objects: title/description, MBTiles metadata, CF standard names, rights/licences, provenance identifiers and labels, bounds and other catalog facts. It does not full-text-index raw scientific cell values. + +## Preview and download + +```text +GET /api/v1/catalog/{id}/tiles/{z}/{x}/{y} +GET /api/v1/catalog/{id}/download +``` + +Both require appropriate role permission and current agreement acceptance. The portrayal endpoint returns only recognized portrayal image payloads from the selected compatibility slice; numeric DNT1 arrays are not converted heuristically. Responses are marked private/no-store where appropriate. + +## DataTiles CRUD + +Users in the bootstrapped `managers` group inherit the `catalog_manager` role and can perform Store-level file CRUD: + +```text +POST /api/v1/catalog multipart `file`; optional `filename` +GET /api/v1/catalog/{id} read indexed metadata +PUT /api/v1/catalog/{id}/file multipart replacement; optional `filename` +PATCH /api/v1/catalog/{id} JSON rename: {"filename":"new.datatiles"} +DELETE /api/v1/catalog/{id} +POST /api/v1/catalog/scan +``` + +Create/update first validates the candidate as a readable DataTiles/MBTiles-compatible SQLite file. Replacement is atomic and re-indexes metadata and SHA-256. A new checksum necessarily invalidates prior access acceptance. These endpoints never edit released scientific metadata in place. + +## User/group administration + +Administrative clients have API counterparts for identity management: + +```text +GET/POST /api/v1/users +PATCH/DELETE /api/v1/users/{id} +GET/POST /api/v1/groups +PATCH/DELETE /api/v1/groups/{id} +``` + +The `administrators` and `managers` bootstrap groups are protected from API deletion. Applications should assign permissions through groups/roles rather than username-specific policy. + +## Audit + +```text +GET /api/v1/audit?limit=100 +``` + +returns operational events such as agreement acceptance, download, catalog create/update/rename/delete, scans, token issuance/revocation and administrative changes. Audit retention and client metadata collection must follow the operator's privacy and records policy. The Store audit is not a substitute for W3C PROV evidence inside DataTiles. + +## Discovery and errors + +```text +GET /api/v1 +GET /api/v1/openapi.json +GET /healthz +``` + +Common API status semantics are `401` authentication required, `403` insufficient role permission, `404` absent asset, `409` state conflict, `415` selected slice is not portrayal imagery, and `428` current agreement acceptance required. + +The reference OpenAPI document is machine-readable discovery metadata. Production deployments may generate a more detailed contract from the same endpoint set and should version incompatible API changes under a new API prefix. + +## Authentication/configuration additions + +API v1 also exposes authentication-provider discovery, managed-account registration/email verification, administrator configuration, and help documentation. Interactive Google/Microsoft/generic OIDC authorization uses browser redirects but resolves to the same Store identity and authorization model. See `store/docs/api.md` and `store/docs/authentication.md`. diff --git a/docs/store-pwa.md b/docs/store-pwa.md new file mode 100644 index 0000000..0d6c404 --- /dev/null +++ b/docs/store-pwa.md @@ -0,0 +1,104 @@ +# DataTiles Store: institutional catalog and PWA + +The optional `/store` application turns a directory of DataTiles releases into a searchable institutional catalog without changing the scientific objects themselves. It is implemented in Python/Flask and uses SQLAlchemy 2.x for all application-database persistence. + +## Architectural separation + +There are two databases with deliberately different purposes: + +1. **DataTiles containers** are immutable or publication-controlled SQLite scientific objects. The store opens them read-only to extract metadata and to serve a selected compatibility portrayal. +2. **The Store database** is an application database managed through SQLAlchemy. It contains users, groups, roles and a denormalized searchable catalog index. It does not become part of the scholarly DataTiles provenance chain. + +This separation avoids accidental modification of published checksums/signatures and permits the catalog index to be rebuilt at any time from authoritative DataTiles files. + +## Catalog discovery and indexing + +A scan walks `CATALOG_DIR`, opens candidate SQLite files through SQLAlchemy in read-only/query-only mode, and extracts ordinary MBTiles metadata plus DataTiles-specific metadata when present: schema revision, semantic variables/CF names, structured rights, provenance entities/activities, integrity signatures, commercial-product metadata, bounds and zoom ranges. Search operates over this extracted index, not over raw tile payloads. + +The catalog records a SHA-256 of each stored file for operational identity. This is separate from revision-6 logical integrity manifests and signatures, whose semantics remain authoritative for signed scholarly releases. + +## Explorer + +The map explorer follows familiar chart-browser interaction patterns: pan/zoom, coverage fitting, layer enable/disable, location inspection, product metadata, variables, provenance, rights and downloads. C-MAP Chart Explorer is a qualitative interaction reference only; its datasets, cartographic symbols, assets and pixels are not copied. + +The preview endpoint consumes the selected four-column `tiles` compatibility view and translates web XYZ `y` to stored TMS rows. Only recognized PNG, JPEG and WebP portrayal payloads are displayed. Numeric DNT1 arrays are never heuristically treated as images. A non-image compatibility slice therefore remains discoverable/downloadable but requires an explicit portrayal profile to appear as a map overlay. + +## Authentication and authorization + +The authorization graph is: + +```text +User -> Group -> Role -> Permission +``` + +The built-in roles are `viewer`, `downloader`, `catalog_manager`, and `admin`. The configuration file defines the bootstrap administrator username/password/group/role. On first application initialization, the `admin` user is created, its configured password is hashed, it is placed in the `administrators` group, and that group receives the `admin` role. + +No application route should infer authorization directly from a username. Route protection uses permissions derived from group-role membership. + +## PWA and confidentiality + +The service worker caches only the static app shell. It explicitly bypasses `/api/*` and file-download responses. Consequently, enabling PWA installation does not automatically create uncontrolled offline copies of protected datasets. + +A production deployment should use HTTPS, a strong random Flask secret, secure cookies, a reverse proxy, database backups, rate limiting at the proxy/gateway, and external secret management. The repository's `config.py` contains an explicit bootstrap password setting as requested, but deployments should override it from a secret manager and rotate it after initialization. + +## FAIR, licences, signatures and DRM + +Authentication and download authorization do not change dataset rights. The application surfaces source-specific acknowledgements, licensing and provenance extracted from each DataTiles release. A restricted catalog can still support FAIR discovery when public metadata/PIDs are published appropriately, but this specific PWA defaults to authenticated catalog access. + +Digital signatures continue to attest the inner scientific object; the Store index is disposable and unsigned. DRM content keys and customer secrets do not belong in the Store catalog database. A commercial deployment can connect the Store to a separate entitlement/KMS service, but that is an integration profile rather than part of the portable Store reference implementation. + +## Versioned licence acceptance and safety/no-liability agreement + +Data availability is not consent. Before a user or API client may retrieve portrayal tiles or download the underlying DataTiles object, the Store requires a current acceptance record tied to four facts: the authenticated user, the catalog item, the exact DataTiles SHA-256, and the current agreement identity. The agreement identity includes the structured rights/licence records extracted from the DataTiles release plus the configured safety/no-liability agreement version. + +The mandatory safety text states that the product is **not suitable for navigation**, is provided **AS IS / AS AVAILABLE**, is not an official chart/ENC/ECDIS or certified navigation aid, requires independent verification against authoritative sources, and limits responsibility of distributors/licensors/contributors/institutions/software providers/service operators to the maximum extent permitted by applicable law. This reference language is a technical default, not jurisdiction-specific legal advice; commercial operators must have counsel review the actual contract and consumer/business-law requirements of their distribution jurisdictions. + +An acceptance row preserves the licence snapshot, file checksum, safety text/version, timestamp, user identity, interaction source (`web` or `api`), and optionally bounded client IP/user-agent evidence. If the file is replaced, its rights change, or the configured safety version changes, the old acceptance remains in the audit history but no longer unlocks preview/download. + +This separation is important academically and legally: accepting the Store agreement cannot create rights absent from upstream licences, waive non-waivable statutory rights, certify data quality, or make a scientific portrayal navigationally authoritative. + +## API-first interaction contract + +Every material Store interaction is exposed under `/api/v1`, including authentication/token lifecycle, catalog search/detail, agreement retrieval and acceptance, portrayal tiles, downloads, catalog rescans, DataTiles CRUD, user/group administration and audit retrieval. Browser routes are a human UI over the same authorization and data model; third-party applications use opaque Bearer tokens associated with an ordinary Store user and therefore receive the same group/role permissions. + +The primary endpoints are: + +```text +POST /api/v1/auth/token +GET /api/v1/auth/me +GET /api/v1/auth/tokens +POST /api/v1/auth/tokens +DELETE /api/v1/auth/tokens/{token_id} + +GET /api/v1/catalog?q=... +POST /api/v1/catalog +GET /api/v1/catalog/{id} +PUT /api/v1/catalog/{id}/file +PATCH /api/v1/catalog/{id} +DELETE /api/v1/catalog/{id} +POST /api/v1/catalog/scan + +GET /api/v1/catalog/{id}/agreement +POST /api/v1/catalog/{id}/agreement/accept +GET /api/v1/catalog/{id}/tiles/{z}/{x}/{y} +GET /api/v1/catalog/{id}/download + +GET/POST/PATCH/DELETE /api/v1/users... +GET/POST/PATCH/DELETE /api/v1/groups... +GET /api/v1/audit +GET /api/v1/openapi.json +``` + +API write endpoints accept Bearer authentication and are CSRF-exempt because they do not rely on ambient browser cookies for third-party authorization. Browser form writes remain CSRF protected. + +## Managers and Store-level CRUD + +The bootstrap process creates a `managers` group (configurable with `MANAGERS_GROUP`) and assigns it the `catalog_manager` role. Members can create/upload, read, replace/update, rename and delete DataTiles files. These are Store-level file operations: update means atomic replacement by another valid DataTiles/MBTiles-compatible SQLite file, not mutation of the signed scientific object in place. + +On replacement, the new file is opened read-only through SQLAlchemy and metadata extraction must succeed before it replaces the published file. A new SHA-256 and metadata index are generated. Because acceptance is checksum-bound, the new release immediately requires renewed licence/safety acceptance. + +Destructive operations and agreement acceptance are recorded in the Store audit table. Audit records are operational evidence and do not replace the W3C PROV graph embedded in the scientific DataTiles object. + +## Authentication and infrastructure integration + +The Store supports managed password accounts, optional verified-email self-registration, Google OpenID Connect, Microsoft Entra ID tenants, and a generic OAuth2/OpenID Connect institutional provider. All identities resolve to the same SQLAlchemy user/group/role model. Runtime authentication and SMTP settings are administered from the PWA **Configuration** section by administrators and through the administrator API. Complete operator documentation is installed under `store/docs/` and rendered by the PWA **Help** section. diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md index 54ee9ff..73dd039 100644 --- a/docs/tutorial/README.md +++ b/docs/tutorial/README.md @@ -1,4 +1,4 @@ -# DataTiles zero to hero in five lessons +# DataTiles zero to hero in ten lessons This course develops DataTiles from first principles and ends with a reproducible mixed raster/vector publication. It treats DataTiles as a scientific data model rather than merely a file-manipulation library. Every lesson contains theory, a hands-on laboratory, observable checks, and questions for critical reflection. @@ -11,6 +11,11 @@ This course develops DataTiles from first principles and ends with a reproducibl | [3. Raster matrices and vector features](lesson-3-raster-vector.md) | Coverage and feature models, content profiles, encoding semantics | Build one container containing DNT1 and tiled GeoJSON | | [4. Querying and deriving live products](lesson-4-query-api.md) | Exact selection, sampling, interpolation boundaries, OGC API patterns | Serve and query point, profile, surface, contour, and content resources | | [5. FAIR and reproducible publication](lesson-5-fair-publication.md) | Identity, provenance, CRS, licences, reproducibility versus replicability | Validate and prepare a citable research artifact | +| [6. Conventions and source utilities](lesson-6-conventions-and-utils.md) | CF semantic identity, NetCDF/GRIB crosswalks, source provenance, declared resampling | Import local or URL-identified scientific grids into semantic DNT1 DataTiles | +| [7. FAIR evidence, provenance, and licensing](lesson-7-fair-provenance-and-licensing.md) | FAIR gates, W3C PROV, SPDX rights, DataCite 4.7, publication evidence | Prepare a defensible scholarly release | +| [8. Cryptographic integrity and signed releases](lesson-8-cryptographic-integrity.md) | canonical logical manifests, Ed25519 signatures, trust, detached envelopes, release verification | Sign and verify a final scholarly DataTiles release | +| [9. Commercial distribution and DRM](lesson-9-commercial-drm.md) | ODRL policy, encrypted `.dtpkg`, recipient licences, key management | Distribute lawful proprietary DataTiles products | +| [10. DataTiles Store PWA](lesson-10-datatiles-store.md) | Flask, SQLAlchemy, PWA, metadata indexing, RBAC, catalog search, interactive exploration | Publish an authenticated institutional DataTiles catalog | ## Prerequisites and setup @@ -36,6 +41,6 @@ The builder is part of the tested repository. It creates two DNT1 matrices and o ## Evidence of completion -A learner completing the course should be able to explain why the `tiles` view is necessary but insufficient, derive a canonical coordinate-set key, distinguish a numeric raster matrix from a portrayal, justify a vector content profile, query exact coordinates without silently interpolating, interpret the FAIR report conservatively, and reproduce the tutorial artifact from source. +A learner completing the course should be able to explain why the `tiles` view is necessary but insufficient, derive a canonical coordinate-set key, distinguish a numeric raster matrix from a portrayal, justify a vector content profile, query exact coordinates without silently interpolating, interpret the FAIR report conservatively, and reproduce the tutorial artifact from source, and explain how NetCDF/CF and GRIB2 source metadata map into the DataTiles semantic-variable convention. The tutorial dataset is for education only and must not be used for navigation. diff --git a/docs/tutorial/examples/build_tutorial.py b/docs/tutorial/examples/build_tutorial.py index 4ce3caf..0683624 100644 --- a/docs/tutorial/examples/build_tutorial.py +++ b/docs/tutorial/examples/build_tutorial.py @@ -27,6 +27,13 @@ def build(path: Path) -> None: store.add_dimension("release","text",axis="O",description="Dataset release identifier") store.add_crs("horizontal",authority="EPSG",code="3857",uri="http://www.opengis.net/def/crs/EPSG/0/3857") store.add_crs("vertical",uri="https://vocab.example.org/datum/tutorial-lat",wkt2="VERTCRS[\"Tutorial depth relative to LAT, positive down\"]") + for name,standard_name,unit in ( + ("depth_below_lat_m","sea_floor_depth_below_sea_surface","m"), + ("seafloor_class","sea_floor_lithology","1"), + ("depth_portrayal","sea_floor_depth_below_sea_surface","m"), + ("survey_stations","platform_id",None), + ): + store.add_variable(name,standard_name,canonical_unit=unit) depth=[5.0,8.0,12.0,20.0,6.0,9.0,14.0,24.0,7.0,10.0,16.0,28.0,8.0,11.0,18.0,32.0] seabed=[4,4,3,6,4,4,3,6,4,3,3,6,5,5,6,6] @@ -53,17 +60,26 @@ def build(path: Path) -> None: encoding="GeoJSON",schema={"geometryTypes":["Point"],"fields":{"name":"String","depth_m":"Number"}}) store.add_provenance_agent("agent:tutorial-author","DataTiles tutorial",agent_type="software") + store.assign_fair_agent_role("agent:tutorial-author","creator") store.add_provenance_activity("activity:tutorial-build","deterministic-build","Build tutorial raster and vector tiles", software="datatiles",parameters={"grid":[4,4],"zoom":0}) source_bytes=json.dumps({"depth":depth,"seabed":seabed,"observations":observations},sort_keys=True,separators=(",",":")).encode() - store.add_provenance_entity("entity:tutorial-source","source","Embedded tutorial observations", + store.add_provenance_entity("entity:tutorial-source","dataset","Embedded tutorial observations", checksum_algorithm="SHA-256",checksum=hashlib.sha256(source_bytes).hexdigest()) - store.add_provenance_entity("entity:tutorial-container","dataset","Tutorial DataTiles container") + store.add_provenance_entity("entity:tutorial-container","collection","Tutorial DataTiles container") store.add_provenance_relation("activity:tutorial-build","used","entity:tutorial-source") store.add_provenance_relation("entity:tutorial-container","wasGeneratedBy","activity:tutorial-build") store.add_provenance_relation("activity:tutorial-build","wasAssociatedWith","agent:tutorial-author") for variable in ("depth_below_lat_m","seafloor_class","depth_portrayal","survey_stations"): store.link_tile_provenance(0,0,0,coordinates(variable),"entity:tutorial-container") + store.add_identifier("URN","urn:uuid:4ea0e809-599d-5f2b-9669-75cd132b9997", + uri="https://example.org/datatiles/tutorial",primary=True) + for scope in ("dataset","metadata"): + store.add_rights(scope,"CC-BY-4.0",license_uri="https://creativecommons.org/licenses/by/4.0/", + rights_holder="DataTiles tutorial",attribution_text="DataTiles tutorial") + store.add_rights("source","CC-BY-4.0",license_uri="https://creativecommons.org/licenses/by/4.0/", + rights_holder="DataTiles tutorial",attribution_text="DataTiles tutorial", + source_entity_id="entity:tutorial-source") metadata={ "description":"Self-contained raster and vector dataset for the DataTiles zero-to-hero course.", diff --git a/docs/tutorial/lesson-10-datatiles-store.md b/docs/tutorial/lesson-10-datatiles-store.md new file mode 100644 index 0000000..8c46199 --- /dev/null +++ b/docs/tutorial/lesson-10-datatiles-store.md @@ -0,0 +1,134 @@ +# Lesson 10 — Publish a DataTiles catalog with the Store PWA + +This lesson turns a directory of finalized DataTiles products into an authenticated searchable catalog and interactive browser. + +## 1. Install the Store + +From the repository root: + +```bash +cd store +python -m pip install -e . +``` + +Edit `store/config.py`. At minimum replace `SECRET_KEY` and `ADMIN_PASSWORD`, and point `CATALOG_DIR` to the directory containing released DataTiles files. + +## 2. Understand the trust boundary + +The Store SQLAlchemy database is not a scientific database. It contains identity/authorization state and a rebuildable metadata search index. Published `.datatiles` files remain the authoritative research objects and are opened read-only. + +This matters for revision-6 signatures: rescanning the Store must not mutate a signed file and must not change its canonical manifest. + +## 3. Start the PWA + +```bash +python run.py +``` + +Open `http://localhost:8080` and sign in with the configured bootstrap administrator. On the first initialization the account is assigned through: + +```text +admin user -> administrators group -> admin role -> all permissions +``` + +## 4. Add products + +Copy finalized DataTiles files under `CATALOG_DIR` and choose **Rescan catalog**, or upload a file while authenticated with `catalog.manage` permission. + +The indexer extracts metadata, CF semantic variables, rights/licence records, provenance summaries, bounds, zoom levels, signature records and public commercial metadata. Search therefore works for queries such as a DOI, `sea_floor_depth_below_geoid`, a rights holder, an SPDX licence expression or a source label. + +## 5. Explore a product + +Select **Explore**. The browser provides pan/zoom, coverage fitting, a portrayal-layer toggle, variables, rights/citation, provenance and metadata panels. + +DataTiles stores TMS rows while web clients use XYZ. The Store converts only at the HTTP interface. A PNG/JPEG/WebP selected compatibility slice is displayable. A DNT1 scientific matrix is not guessed into colors: prepare/select a declared portrayal profile when a preview is required. + +The permanent **Not for navigation** notice remains visible for the reference workflow. + +## 6. Add users and groups + +The built-in roles provide progressively broader permissions. Create groups such as `researchers`, `customers`, or `publishers`, assign the appropriate roles, then create users in those groups. Do not create authorization checks based on hard-coded usernames. + +## 7. Deploy as a PWA safely + +The service worker makes the user interface installable but does not cache APIs, map payloads or file downloads. In production place Flask behind HTTPS, enable secure session cookies, keep the secret key and bootstrap password in a secret manager, and use a production WSGI server. + +## 8. Commercial products + +The Store is not the revision-7 DRM entitlement server. For proprietary products, keep content keys and customer private material in a dedicated KMS/licence service. Catalog metadata may advertise the product, issuer and terms, while actual authorization/decryption remains an independently secured subsystem. + +## 9. Publication checklist + +Before exposing any product, verify that its source-specific citation, rights, FAIR metadata, provenance and optional signature evidence are complete. Download authorization cannot cure a missing attribution or incompatible upstream licence, and an interactive preview does not certify the data for navigation. + +## 10. Require explicit licence and safety acceptance + +Open a product as a user with download permission. The Store shows the rights extracted from the DataTiles release together with the configured **not suitable for navigation / AS IS / limitation-of-responsibility** agreement. Preview and download remain unavailable until both checkboxes are explicitly accepted. + +The acceptance is version-specific. Replace the file with a different release and observe that the old acceptance no longer authorizes access because the SHA-256 changed. The same happens when embedded rights change or the operator advances `SAFETY_AGREEMENT_VERSION`. + +A third-party client performs the same workflow: + +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/token \ + -H 'Content-Type: application/json' \ + -d '{"username":"customer","password":"a-long-customer-password"}' | jq -r .access_token) + +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/api/v1/catalog/1/agreement + +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"accept_license":true,"accept_safety":true}' \ + http://localhost:8080/api/v1/catalog/1/agreement/accept + +curl -L -H "Authorization: Bearer $TOKEN" \ + -o product.datatiles \ + http://localhost:8080/api/v1/catalog/1/download +``` + +## 11. Use the managers group for DataTiles CRUD + +The Store automatically creates the configured `managers` group with the `catalog_manager` role. Add a publishing user to this group. That user can upload a new DataTiles product, replace an existing release atomically, rename the stored file, or delete it. + +Equivalent API calls use `POST /api/v1/catalog`, `PUT /api/v1/catalog/{id}/file`, `PATCH /api/v1/catalog/{id}`, and `DELETE /api/v1/catalog/{id}`. The API enforces the same permissions as the browser. + +Do not use CRUD to rewrite the scientific SQLite object in place. Build/finalize/sign a new release first, then replace the catalog asset. This preserves the distinction between scholarly provenance and distribution operations. + +## 12. Treat the API as the integration contract + +Use `/api/v1/openapi.json` for machine discovery. Third-party portals, desktop GIS/MFD applications, institutional repositories and commercial entitlement frontends should integrate through the API rather than scraping HTML. Bearer tokens resolve to ordinary users and inherit exactly the roles granted through their groups. + +The Store audit endpoint records operational actions such as agreement acceptance, downloads, uploads, replacements and deletes. Keep this audit database protected and retained according to institutional privacy/records policies; it is not a replacement for source provenance inside DataTiles. + +## Identity integration and administrator configuration + +The Store can use managed passwords, verified-email registration, Google OpenID Connect, Microsoft Entra ID tenants, or a generic OAuth2/OpenID Connect provider. Open the administrator **Configuration** section to enable providers, enter client credentials, configure the public callback URL and SMTP delivery, and choose whether new-user registration is allowed. All external identities still map into the same Store groups/roles; SSO is authentication, not automatic administrative authorization. + +The complete operational manual is available inside the PWA **Help** section from the Markdown sources under `store/docs/`. Use `/api/v1/configuration`, `/api/v1/auth/providers`, `/api/v1/register`, and `/api/v1/help` when integrating infrastructure or third-party clients. + +## 13. Publish versioned releases + +Schema revision 8 adds `DataTiles-Release-Versioning-1`. Each release may declare a stable `product_id`, a human-facing `version`, a monotonically increasing integer `sequence`, `released_at`, and optional predecessor/release-notes/update links. Use `sequence` for ordering; do not sort arbitrary version strings lexically. + +A published version is immutable. Corrections and updates are new DataTiles files with a larger sequence, a new checksum, and a new signature when cryptographic signing is used. The Store indexes release metadata and can identify successors without rewriting historical files. + +## 14. Configure optional payments + +Open **Configuration → Commerce / PayPal** as an administrator. Payment is disabled by default. Enable commerce, select the provider, and configure the PayPal reference adapter in sandbox mode first. The PayPal implementation uses server-side OAuth credentials and Orders v2; client secrets never enter DataTiles files or browser JavaScript. + +Managers can mark a catalog release as purchase-required and assign price/currency. Licence/safety acceptance and payment entitlement are separate gates: payment never overrides an upstream licence or acknowledgement obligation. + +Third-party clients can discover the provider and create/capture checkout transactions using: + +```text +GET /api/v1/payments/providers +POST /api/v1/catalog/{id}/checkout +POST /api/v1/payments/{transaction_id}/capture +``` + +## 15. Use My Library and update notifications + +Every successful Store download is recorded with the exact release and SHA-256. Completed paid transactions create release-specific purchase records. Users can inspect both histories in **My Library** or with `GET /api/v1/library`. + +When a higher `sequence` of the same stable `product_id` is indexed, users who previously purchased or downloaded an older release can receive an update notification. The notification is only discovery evidence: it does not automatically transfer a paid entitlement to the newer release. diff --git a/docs/tutorial/lesson-6-conventions-and-utils.md b/docs/tutorial/lesson-6-conventions-and-utils.md new file mode 100644 index 0000000..d9a36c7 --- /dev/null +++ b/docs/tutorial/lesson-6-conventions-and-utils.md @@ -0,0 +1,160 @@ +# Lesson 6 — DataTiles conventions and source utilities + +## Learning goals + +This chapter connects the DataTiles container model to existing scientific conventions. At the end you should be able to explain why a local variable name is insufficient for interoperability, map NetCDF/CF, GRIB2, and Zarr metadata into DataTiles, import local or URL-identified sources, and verify that imported numeric tiles remain data rather than pre-rendered cartography. + +## 1. Convention before conversion + +A file format answers *how bytes are encoded*. A convention or controlled vocabulary answers *what a quantity means*. A NetCDF variable called `depth`, a GRIB short name, and a provider field called `z` may refer to the same physical quantity—or to materially different vertical references. DataTiles therefore separates the local variable token used in coordinates from the semantic variable registry. + +The preferred canonical vocabulary is CF Standard Names. External identifiers such as WMO GRIB2 parameter triples and SeaDataNet/NERC identifiers are crosswalks. Units, CRS/datum and provenance remain explicit because a standard variable name alone cannot fully define every geospatial measurement. + +A useful interoperability test is: + +```text +producer-local name -> controlled semantic name -> units/datum/CRS -> DataTiles coordinate +``` + +A consumer should be able to discover the semantic name without knowing the producer-local name. + +## 2. NetCDF convention mapping + +Consider a NetCDF variable: + +```text +float depth(time, lat, lon) + standard_name = "sea_floor_depth_below_geoid" + units = "m" +``` + +The importer maps it conceptually to: + +```text +variable coordinate token: depth +standard vocabulary: CF +standard name: sea_floor_depth_below_geoid +unit: m +other dimension: time +horizontal target CRS: EPSG:3857 +payload: DNT1 numeric matrix +``` + +If `standard_name` is missing, `netcdf2datatiles` does **not** invent a CF name. The token is registered with vocabulary `source` and can be curated later. + +## 3. GRIB2 convention mapping + +GRIB2 identifies parameters primarily using WMO tables. `grib2datatiles` decodes the file with cfgrib/ecCodes and preserves both semantic views when available: + +```text +CF standard_name canonical semantic discovery +WMO discipline.category.parameter GRIB2 crosswalk +GRIB shortName source/ecCodes crosswalk +``` + +This is why DataTiles should not choose either NetCDF variable names or GRIB codes as its only universal naming system. + +## 4. Install the import profile + +The DataTiles core remains dependency-free. Import utilities are an optional profile: + +```bash +python -m pip install -e '.[utils]' +``` + +This installs xarray/NetCDF support, the cfgrib/ecCodes stack required for GRIB, and the Zarr/fsspec stack required for chunked local or remote stores. + +## 5. Import a local NetCDF file + +```bash +python utils/netcdf2datatiles.py samples/ocean.nc ocean.datatiles \ + --variable depth \ + --zoom 7 \ + --bbox 12.8 39.9 15.8 41.3 +``` + +The source must expose a rectilinear latitude/longitude grid. The converter creates typed DataTiles dimensions for every non-spatial dimension, registers the semantic variable, resamples the selected grid to Web Mercator tile pixel centers with nearest-neighbour sampling, encodes the matrices as DNT1, and records source SHA-256 provenance. + +Inspect the result: + +```bash +datatiles variables ocean.datatiles +datatiles validate ocean.datatiles --require-variable-semantics +``` + +## 6. Import from a URL + +The source identifier can be HTTP(S): + +```bash +python utils/netcdf2datatiles.py \ + https://example.org/archive/ocean.nc \ + ocean.datatiles --variable depth --zoom 7 +``` + +The URL is downloaded to a temporary seekable file, hashed while streaming, converted, then deleted. The DataTiles provenance stores the original URL plus SHA-256 checksum. Reproducible publication should prefer immutable or versioned source URLs and independently published checksums where available. + +## 7. Import GRIB2 + +```bash +python utils/grib2datatiles.py forecast.grib2 forecast.datatiles \ + --variable t2m --zoom 6 +``` + +A GRIB file may contain messages that cannot be represented as one xarray hypercube. Narrow the cfgrib view with repeatable keys: + +```bash +python utils/grib2datatiles.py forecast.grib2 pressure.datatiles \ + --filter-by-keys typeOfLevel=isobaricInhPa \ + --variable t --zoom 6 +``` + +After import, query by semantic identity rather than by source encoding whenever possible: + +```bash +datatiles find-variable forecast.datatiles air_temperature +``` + +## 8. Import Zarr + +A local Zarr directory store is imported directly: + +```bash +python utils/zarr2datatiles.py samples/ocean.zarr ocean-zarr.datatiles \ + --variable depth --zoom 7 +``` + +A Zarr store is not one file. For a local store, DataTiles computes a deterministic digest over the sorted store keys, byte lengths, and exact object bytes (`zarr-tree-sha256-v1`). For a remote immutable store, supply the archival checksum published for that snapshot: + +```bash +python utils/zarr2datatiles.py \ + https://example.org/archive/ocean.zarr ocean-zarr.datatiles \ + --source-sha256 <64-hex-authoritative-sha256> \ + --group analysis --consolidated true --variable depth --zoom 7 +``` + +Object-store URLs such as `s3://` and `gcs://` can be used when the corresponding fsspec backend is installed. Runtime credentials belong in backend configuration or storage options; secret values must never enter DataTiles provenance. The converter records only storage-option names. A signed or credential-bearing access URL requires `--provenance-uri` with a stable credential-free source/PID URI. + +Zarr v2 and v3 differ in metadata and fill-value behavior. Use `--zarr-format 2` or `--zarr-format 3` when the publication workflow needs to require a specific format. Consolidated metadata improves store discovery but is not evidence of scientific validity or stronger provenance. + +## 9. Why the utility does not create image tiles + +The output is `application/vnd.datatiles.numeric` using DNT1. A color ramp, contour layer, shaded relief or nautical-style display is a reproducible *derivation* from the numeric evidence. Converting scientific source grids directly into PNG tiles would destroy machine-readable values and undermine the interoperability objective of this chapter. + +## 10. Limits are part of the convention + +The first utility profile intentionally rejects unsupported geometry rather than approximating it silently. Curvilinear coordinates, rotated grids, projected source grids, antimeridian-spanning extents and conservative cell-area remapping need explicit algorithms and provenance. `--max-tiles` is also part of the safety model: a mistaken high zoom must not expand an import without a deliberate operator decision. + +## 11. Laboratory checks + +Run an import and confirm all of the following: the source URI/checksum are present in metadata/provenance; `datatiles variables` exposes a CF name or explicitly says `source`; the DNT1 unit matches the imported variable unit; time/pressure/depth dimensions are typed rather than embedded in filenames; the horizontal CRS is explicit; and no raster portrayal has been substituted for the numeric payload. + +### Reflection + +1. Why is `depth` insufficient as a universal semantic identifier? +2. When should a WMO GRIB2 parameter be a crosswalk rather than the canonical name? +3. Why must an importer refuse to manufacture a CF `standard_name`? +4. Which provenance fields make a URL import reproducible? +5. Why is nearest-neighbour resampling acceptable only when declared explicitly? +6. Why must a Zarr directory store not be described as having the SHA-256 of a single file? +7. Why should credentials for S3/GCS/HTTP backends never be copied into provenance metadata? diff --git a/docs/tutorial/lesson-7-fair-provenance-and-licensing.md b/docs/tutorial/lesson-7-fair-provenance-and-licensing.md new file mode 100644 index 0000000..af1bffb --- /dev/null +++ b/docs/tutorial/lesson-7-fair-provenance-and-licensing.md @@ -0,0 +1,89 @@ +# Lesson 7 — FAIR evidence, provenance, and licensing + +## Objectives + +You will turn a technically valid DataTiles file into a defensible scholarly research object by separating persistent identity, scientific provenance, legal rights, citation metadata, and repository-level FAIR evidence. + +## 1. FAIR is not an adjective + +The FAIR principles are individually testable obligations. A file can be richly structured but still fail F1/F4/A2 because no PID, catalogue deposit, or metadata-retention policy exists. Conversely, restricted data can be FAIR when metadata is persistent, access conditions are explicit, and authorized reuse is well described. + +Run both local and publication-level checks: + +```python +from datatiles import DataTiles +with DataTiles("study.datatiles") as s: + print(s.fair_report()) + print(s.fair_report(strict_publication=True)) +``` + +Explain every failed principle instead of reporting a percentage. + +## 2. Record identity and scholarly relationships + +A released revision receives a PID. Do not reuse one DOI for materially different immutable versions unless the repository's versioning model explicitly supports it. + +```python +s.add_identifier("DOI", "10.xxxx/example.v1", uri="https://doi.org/10.xxxx/example.v1", primary=True) +s.add_related_identifier("DOI", "10.xxxx/source", "IsDerivedFrom", uri="https://doi.org/10.xxxx/source", resource_type="Dataset") +``` + +## 3. Separate rights layers + +```python +s.add_rights("dataset", "CC-BY-4.0", + license_uri="https://creativecommons.org/licenses/by/4.0/", + rights_holder="Example University", + attribution_text="Cite the dataset DOI and named creators") +s.add_rights("metadata", "CC0-1.0", + license_uri="https://creativecommons.org/publicdomain/zero/1.0/") +``` + +Every source entity receives its own `source` rights record. Never infer source rights from the output licence. + +## 4. Build a PROV graph + +Create source and generated entities, the transformation activity, software and responsible agents. Connect source -> activity -> generated object with `used`, `wasGeneratedBy`, `wasDerivedFrom`, and activity-agent associations. Record exact parameters and software versions. + +Export the graph: + +```python +import json +print(json.dumps(s.prov_json(), indent=2)) +``` + +Audit the graph: can an independent researcher identify every input that materially influenced a tile and the transformation that produced it? + +## 5. Prepare DataCite metadata + +```python +print(json.dumps(s.datacite_metadata(), indent=2)) +``` + +This is a DataCite-4.7-shaped candidate record. It is **not** evidence that a DOI exists. Deposit it through the chosen repository, then record catalogue/landing-page/retention evidence in the container or release manifest. + +## 6. Use the converters lawfully + +```bash +python utils/netcdf2datatiles.py source.nc study.datatiles \ + --variable depth --zoom 7 \ + --source-license CC-BY-4.0 \ + --source-license-uri https://creativecommons.org/licenses/by/4.0/ \ + --source-attribution "Source provider citation/attribution" \ + --dataset-license CC-BY-4.0 \ + --dataset-license-uri https://creativecommons.org/licenses/by/4.0/ +``` + +The utility records the exact source identifier, SHA-256, rights record and conversion activity. It does not decide licence compatibility for you. + +## 7. Publication exercise + +Prepare an evidence package containing the DataTiles file, checksums, source lock, rights manifest, runtime lock, configuration, PROV export, DataCite export, FAIR report and scientific QA. For a source that forbids redistribution, omit its raw bytes and document lawful reacquisition while retaining its checksum and provenance identity. + +## Reflection + +1. Why does a SHA-256 digest not replace a citation or licence? +2. Why must source and output licences be separate records? +3. Which FAIR principles cannot be proven by the SQLite container alone? +4. What is the difference between `wasDerivedFrom` and `used` in the provenance graph? +5. Why is a DataCite JSON export not proof of DOI registration? diff --git a/docs/tutorial/lesson-8-cryptographic-integrity.md b/docs/tutorial/lesson-8-cryptographic-integrity.md new file mode 100644 index 0000000..269847a --- /dev/null +++ b/docs/tutorial/lesson-8-cryptographic-integrity.md @@ -0,0 +1,71 @@ +# Lesson 8 — Cryptographic integrity and signed releases + +This lesson turns a reproducible DataTiles build into an optionally signed research object. Complete the scientific build, citations, licences, provenance, and FAIR release checks **before** signing; every later scientific mutation intentionally invalidates the signature. + +## 1. Why a signature is different from a checksum + +A SHA-256 checksum identifies bytes or logical content. A digital signature binds a digest to possession of a private key. DataTiles signs a canonical logical manifest so harmless SQLite storage rewrites do not break authenticity while changes to scientific tables do. + +Trust remains external: an embedded public key proves only self-consistency. Obtain a trusted public key through an institutional repository, project website, certificate infrastructure, signed Git release, or another independently authenticated channel. + +## 2. Install optional signing support + +```bash +python -m pip install -e '.[integrity]' +``` + +The base DataTiles package remains dependency-free; signing support is intentionally optional. + +## 3. Inspect the unsigned manifest + +```bash +datatiles-integrity manifest gaeta-maratea.datatiles \ + --output gaeta-maratea.integrity.json +``` + +Inspect `root_sha256`, table row counts, per-table digests, and schema revision. This file is deterministic for the same logical signed domain. + +## 4. Generate a release key + +For this exercise only: + +```bash +datatiles-integrity generate-key \ + --private-key tutorial-release-key.pem \ + --public-key tutorial-release-key.pub.pem +``` + +Production institutions should use their established key-management policy. Never publish the private key. + +## 5. Sign the final object + +```bash +datatiles-integrity sign gaeta-maratea.datatiles \ + --private-key tutorial-release-key.pem \ + --signer 'Tutorial publisher' \ + --detached gaeta-maratea.datatiles.sig.json +``` + +The command stores a signature in the container and creates a detached envelope suitable for a repository release. + +## 6. Verify with an independently obtained key + +```bash +datatiles-integrity verify gaeta-maratea.datatiles \ + --detached gaeta-maratea.datatiles.sig.json \ + --public-key tutorial-release-key.pub.pem +``` + +The report must show `cryptographically_valid: true`, `trusted_key_supplied: true`, `content_matches_signed_manifest: true`, and `valid: true`. + +Now copy the DataTiles file, modify one metadata value in the copy, and verify again. The signature remains mathematically valid over its historical manifest, but `content_matches_signed_manifest` becomes false. This separation is essential for forensic clarity. + +## 7. Connect signer and provenance + +Create or reuse a PROV agent representing the responsible person or organization and record its persistent identifier. When signing inside a publication workflow, pass its identifier as `--signer-agent-id`. The agent identity and cryptographic key identity remain distinct. + +## 8. Release checklist + +A signed scholarly release should archive the DataTiles object, detached signature, trusted public key/certificate reference, source manifest, provenance graph, source-specific citations, rights records, DataCite metadata, strict FAIR report, and any optional Sigstore/timestamp evidence. + +A signature is not a navigation approval. The Gaeta-to-Maratea product remains **Not for navigation** regardless of signature status. diff --git a/docs/tutorial/lesson-9-commercial-drm.md b/docs/tutorial/lesson-9-commercial-drm.md new file mode 100644 index 0000000..399d3dd --- /dev/null +++ b/docs/tutorial/lesson-9-commercial-drm.md @@ -0,0 +1,39 @@ +# Lesson 9 — Commercial distribution and DRM + +This lesson shows how a lawful proprietary DataTiles release can add technical access control without weakening FAIR metadata, provenance, licensing, citation, or cryptographic integrity. + +## 1. Decide whether commercial distribution is legally permitted + +Before encryption, inspect every source rights record and the frozen contribution manifest. The ability to encrypt a derivative does not grant commercial redistribution rights. Resolve attribution, share-alike, database rights, non-commercial restrictions, and contractual terms first. + +## 2. Register the product and policy + +Give the inner DataTiles object a stable product ID, publisher identity, edition, terms URI, optional licence-service URI, and machine-readable ODRL policy. Keep these facts inside the signed logical object so that policy metadata cannot be silently changed without invalidating the DataTiles integrity signature. + +## 3. Freeze and optionally sign + +Run FAIR/provenance/licensing checks, freeze the product, and optionally sign it with `datatiles-integrity`. The signature authenticates the exact scientific release; DRM comes afterward as a distribution wrapper. + +## 4. Protect the product + +Use `datatiles-drm protect`. The command generates a fresh AES-256 content key, encrypts the entire DataTiles file with authenticated encryption, records the plaintext SHA-256 in the authenticated package header, and writes the publisher content key to a restricted file. Move that secret to institutional key management as soon as practical. + +## 5. Provision a recipient + +Each recipient/device has an X25519 keypair. Only the public key is supplied to the publisher. The private key stays with the customer/device. + +## 6. Issue a licence + +The publisher wraps the content key to that recipient and signs the licence with its Ed25519 issuer key. The licence can include validity dates, a recipient identifier, technical permissions, and an ODRL policy. The same encrypted `.dtpkg` can therefore be distributed once while issuing different recipient-specific grants. + +## 7. Verify and decrypt + +The client verifies the issuer signature using an independently authenticated publisher public key, checks time/product/hash/recipient binding, unwraps the content key, authenticates/decrypts the package, and verifies the recovered DataTiles SHA-256. + +## 8. Understand the limits + +Portable DRM cannot guarantee that an authorized client will never copy plaintext. It protects distribution and access, not post-decryption behavior. Never market DRM as scientific certification, FAIR certification, copyright ownership, or navigation safety. + +## Exercise + +Create a small DataTiles release, sign it, protect it once, issue two licences for two recipient public keys, verify that each recipient can decrypt using only its own private key, and demonstrate that a licence copied to the other recipient fails. Then expire one licence and verify that decryption is denied. diff --git a/docs/white-paper-data-sources-addendum.md b/docs/white-paper-data-sources-addendum.md new file mode 100644 index 0000000..020c456 --- /dev/null +++ b/docs/white-paper-data-sources-addendum.md @@ -0,0 +1,20 @@ +## Data stewardship, source-specific citation, and licence boundaries + +An edge evidence substrate is scientifically defensible only when the identity and legal status of its inputs survive transformation. DataTiles therefore treats **data acknowledgement as executable release evidence**, not as generic prose. A derived tile pyramid, MBTiles compatibility export, map, figure, service, model input, or paper MUST be traceable to the exact source objects that contributed cells or features. The frozen run manifest is the authority for contributor status. + +The From Gaeta to Maratea reference build illustrates this principle. Its primary finite bathymetry is the JammeGaia22/MGDS multi-resolution multibeam product (Foglini, Tonielli & Rovere, 2024; doi:10.60521/331667). EMODnet Digital Bathymetry DTM 2024 (doi:10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1) is a conditional fallback only where JammeGaia22 has no finite measurement. This priority rule is scientific provenance: a consumer must be able to distinguish measured/local primary cells from harmonised fallback cells. Source coverage is therefore retained as data rather than erased after fusion. + +Land/ocean topology is an independent evidence class. GSHHG 2.3.7, cited through Wessel & Smith (1996; doi:10.1029/96JB00104), provides hierarchical shoreline/topology information. When enabled, S2Coast-2023 (Duan et al.; doi:10.5281/zenodo.17092775) contributes a Sentinel-2-derived high-water-line/coastline fact. These products must not be collapsed into a single undocumented notion of “coastline”: their definitions, resolutions, epochs, licences, and error modes differ. OpenStreetMap context remains separately attributable as © OpenStreetMap contributors under ODbL 1.0. + +This separation is both FAIR and operational. Findability requires persistent identifiers and indexed metadata; accessibility requires resolvable, documented access conditions; interoperability requires controlled semantics, units, CRS/datums, and explicit source roles; reusability requires qualified provenance, licences, attribution, limitations, and transformation history. A downloadable object is not necessarily reusable, and an open-source software licence does not relicense source data. + +DataTiles release engineering consequently applies four invariants: + +1. **No anonymous contribution.** Every contributing source is represented by a PID/stable URI, immutable object identity, rights record, and provenance entity. +2. **No generic acknowledgement.** Visible credits are source-specific and must agree with the frozen manifest. Candidate sources that contributed nothing are not credited as data contributors. +3. **No licence laundering.** Software, source-data, generated-dataset, metadata, and portrayal rights remain distinguishable. Combining data does not silently erase upstream obligations. +4. **No provenance-free fusion.** Priority, masking, resampling, reprojection, fallback and feature-selection decisions are generating activities with parameters and software identity; conditional fusion retains a source-coverage field or equivalent lineage evidence. + +For the current reference production build, the complete bibliographic records, required short-form map credit, licence statements, coverage/resolution cautions, manifest requirements, and FAIR release checklist are normative project guidance in `docs/data_sources_and_citation.md`. The map credit is a projection of the actual run evidence, not immutable boilerplate. GMRT, GEBCO, EMODnet thematic products, ISPRA, and other candidate/reference datasets must not be represented as contributors unless the specific run manifest proves their use. + +This discipline also constrains AI and statistical consumers. A model must be able to retrieve not merely a depth value but the source identity and transformation lineage that produced it. Where primary and fallback datasets differ in resolution, survey age, datum, or uncertainty, that distinction is part of the feature contract and may affect applicability. FAIR stewardship therefore becomes part of uncertainty management and reproducibility rather than a post-publication metadata exercise. diff --git a/docs/white-paper.md b/docs/white-paper.md index bc4d287..5c19a15 100644 --- a/docs/white-paper.md +++ b/docs/white-paper.md @@ -35,6 +35,35 @@ The same model can tile road geometry, grade, curvature, friction estimates, sur DataTiles must remain outside the safety boundary unless the complete system is engineered and assessed for that role. A portrayal is not a drivable-world model. Stale roadworks, uncertain floods, coordinate errors, or distribution shift can be hazardous. Deployment therefore needs integrity levels, authenticated updates, freshness rules, fail-operational or fail-safe behavior, independent sensing, runtime monitors, and applicable functional-safety and safety-of-the-intended-functionality processes. +## Data stewardship, source-specific citation, and licence boundaries + +An edge evidence substrate is scientifically defensible only when the identity and legal status of its inputs survive transformation. DataTiles therefore treats **data acknowledgement as executable release evidence**, not as generic prose. A derived tile pyramid, MBTiles compatibility export, map, figure, service, model input, or paper MUST be traceable to the exact source objects that contributed cells or features. The frozen run manifest is the authority for contributor status. + +The From Gaeta to Maratea reference build illustrates this principle. Its primary finite bathymetry is the JammeGaia22/MGDS multi-resolution multibeam product (Foglini, Tonielli & Rovere, 2024; doi:10.60521/331667). EMODnet Digital Bathymetry DTM 2024 (doi:10.12770/cf51df64-56f9-4a99-b1aa-36b8d7b743a1) is a conditional fallback only where JammeGaia22 has no finite measurement. This priority rule is scientific provenance: a consumer must be able to distinguish measured/local primary cells from harmonised fallback cells. Source coverage is therefore retained as data rather than erased after fusion. + +Land/ocean topology is an independent evidence class. GSHHG 2.3.7, cited through Wessel & Smith (1996; doi:10.1029/96JB00104), provides hierarchical shoreline/topology information. When enabled, S2Coast-2023 (Duan et al.; doi:10.5281/zenodo.17092775) contributes a Sentinel-2-derived high-water-line/coastline fact. These products must not be collapsed into a single undocumented notion of “coastline”: their definitions, resolutions, epochs, licences, and error modes differ. OpenStreetMap context remains separately attributable as © OpenStreetMap contributors under ODbL 1.0. + +This separation is both FAIR and operational. Findability requires persistent identifiers and indexed metadata; accessibility requires resolvable, documented access conditions; interoperability requires controlled semantics, units, CRS/datums, and explicit source roles; reusability requires qualified provenance, licences, attribution, limitations, and transformation history. A downloadable object is not necessarily reusable, and an open-source software licence does not relicense source data. + +DataTiles release engineering consequently applies four invariants: + +1. **No anonymous contribution.** Every contributing source is represented by a PID/stable URI, immutable object identity, rights record, and provenance entity. +2. **No generic acknowledgement.** Visible credits are source-specific and must agree with the frozen manifest. Candidate sources that contributed nothing are not credited as data contributors. +3. **No licence laundering.** Software, source-data, generated-dataset, metadata, and portrayal rights remain distinguishable. Combining data does not silently erase upstream obligations. +4. **No provenance-free fusion.** Priority, masking, resampling, reprojection, fallback and feature-selection decisions are generating activities with parameters and software identity; conditional fusion retains a source-coverage field or equivalent lineage evidence. + +For the current reference production build, the complete bibliographic records, required short-form map credit, licence statements, coverage/resolution cautions, manifest requirements, and FAIR release checklist are normative project guidance in `docs/data_sources_and_citation.md`. The map credit is a projection of the actual run evidence, not immutable boilerplate. GMRT, GEBCO, EMODnet thematic products, ISPRA, and other candidate/reference datasets must not be represented as contributors unless the specific run manifest proves their use. + +This discipline also constrains AI and statistical consumers. A model must be able to retrieve not merely a depth value but the source identity and transformation lineage that produced it. Where primary and fallback datasets differ in resolution, survey age, datum, or uncertainty, that distinction is part of the feature contract and may affect applicability. FAIR stewardship therefore becomes part of uncertainty management and reproducibility rather than a post-publication metadata exercise. + +## Cryptographic integrity, authenticity, and scholarly chain of custody + +DataTiles optionally signs a canonical logical manifest of the complete scientific object. This is deliberately stronger than attaching a checksum to the SQLite file: the manifest is invariant to storage-layout operations such as VACUUM while remaining sensitive to changes in scientific cells, vectors, metadata, semantics, provenance, rights, selected-slice state, and signed schema definitions. The native profile uses SHA-256 and Ed25519; the signing tables are excluded from the signed domain so multiple independent signatures can attest the same immutable scientific state. + +The trust claim is intentionally narrow. A mathematically valid signature proves possession of the corresponding private key at signing time, not the institutional identity of that key holder. Publication workflows must authenticate public keys independently, bind signers to persistent scholarly agents, preserve historical keys through rotation, and archive detached signatures and verification material with the research object. Sigstore/in-toto evidence can supplement the offline profile with certificate identity, transparency inclusion, and timestamps, but is not required for basic DataTiles verification. + +Cryptographic signatures complement FAIR metadata, W3C PROV lineage, DataCite identity, source-specific citation, and SPDX rights; they do not replace any of them. Nor do they establish scientific truth, licence compatibility, hydrographic authority, or navigation fitness. An unsigned object can still be FAIR; an institution may separately impose a signed-release policy for chain-of-custody assurance. + ## AI evidence contract Every onboard inference should identify: @@ -79,3 +108,21 @@ Cybersecurity matters because a plausible malicious tile can alter an AI decisio Future work includes uncertainty profiles, signed tile manifests, incremental synchronization, content-addressed deduplication, safety-oriented freshness policies, model/provenance profiles, non-Web-Mercator matrices, efficient vector/numeric co-query, and evaluation on real edge hardware. DataTiles is a bridge between scientific stewardship and edge intelligence. Its claim is not that a SQLite file makes autonomy safe. Its contribution is to make the data boundary explicit, local, inspectable, reproducible, interoperable, and accountable—conditions that systems protecting life and the environment need before algorithms can be trusted. + +## Commercial distribution, rights expression, and DRM + +DataTiles does not equate FAIR with open or zero-cost access. Commercial and institutionally restricted research/cartographic products can remain machine-discoverable and semantically interoperable while the data payload is distributed under controlled access. Revision 7 therefore separates four layers: scholarly/data rights, machine-readable ODRL usage policy, cryptographic integrity/authenticity, and optional DRM enforcement. + +The portable DRM profile wraps the finalized DataTiles SQLite object rather than encrypting individual scientific rows. This preserves the canonical research object, allows the existing integrity signature to attest the exact plaintext release, and ensures authorized decryption recovers byte-identical DataTiles content. AES-256-GCM provides authenticated confidentiality; recipient-specific X25519/HKDF key wrapping avoids sharing a universal customer secret; Ed25519 issuer signatures authenticate licence grants. + +DRM cannot manufacture legal rights. A publisher may sell a derivative only when every contributing source and contractual term permits the intended commercial exploitation. Source-specific citation, attribution, provenance, licence compatibility, and database-right obligations remain enforceable independently of the encryption layer. Nor can portable DRM prevent an authorized endpoint from copying plaintext after decryption. It should therefore be described as distribution/access control, not as an absolute anti-copy guarantee. + +For FAIR publication, public metadata should disclose the existence of access restrictions, product identity, terms, issuer, and access mechanism even when the payload requires purchase or authorization. Conversely, secrets, customer identifiers not required for scholarly provenance, private keys, and payment information are intentionally excluded from the scientific object and FAIR graph. + +## Versioned dissemination, acquisition history, and payment abstraction + +A scientific data product must distinguish identity of the logical product from identity of an individual release. Revision 8 therefore introduces a stable product identifier and an explicit monotonic release sequence in addition to the human-facing version label. This design prevents ambiguous lexical version ordering and supports reproducible citation of the exact release used in analysis. Published versioned objects are immutable; an update is a successor object, not a silent byte replacement. + +The Store uses this release identity to maintain user-level acquisition evidence. Purchases and downloads are separate records: a purchase represents a commercial entitlement transaction, while a download records actual delivery of an exact checksum-identified release. A higher sequence for the same product can generate an update notification to prior users without implying that the successor is free, licensed identically, or scientifically interchangeable. + +Commercial payment processing is intentionally abstracted from the scientific format and Store entitlement schema. A payment provider implements a narrow checkout/capture contract; PayPal Orders v2 is the reference implementation. Provider credentials and financial payment details remain outside DataTiles provenance. Commercial entitlement, data licensing, FAIR evidence, provenance, cryptographic signatures, DRM, scientific validity, and navigation fitness remain independent claims that must not be conflated. diff --git a/docs/zarr-source-profile.md b/docs/zarr-source-profile.md new file mode 100644 index 0000000..29bc465 --- /dev/null +++ b/docs/zarr-source-profile.md @@ -0,0 +1,78 @@ +# Zarr source-ingestion profile + +This document defines the DataTiles utility profile for importing Zarr datasets. It is normative for `utils/zarr2datatiles.py` and informative for independent implementations unless they claim this profile. + +## Scope + +Zarr is a chunked, N-dimensional storage format. DataTiles treats Zarr as a **source encoding**, not as the semantic identity of a scientific quantity. An importer MUST preserve or explicitly map variable semantics, units, scientific dimensions, source identity, provenance, rights, and transformation parameters before writing DNT1 numeric tiles. + +The profile supports Zarr format 2 and 3 stores that xarray can interpret as a Dataset. The source data variables used by the profile MUST expose rectilinear one-dimensional latitude and longitude coordinates. Unsupported curvilinear, projected, rotated, or antimeridian-crossing source grids MUST be rejected rather than silently approximated. + +## Source identifiers + +A source MAY be: + +- a local directory store or `file:` URI; +- an HTTP(S) URL; +- another fsspec URL such as `s3://` or `gcs://`, provided the required backend is installed and the operator supplies any necessary storage options. + +Authentication material is runtime configuration and MUST NOT be copied into DataTiles metadata, provenance attributes, logs, manifests, or tutorial artifacts. The importer records storage-option **keys only**, never secret values. + +## Store identity and checksums + +A Zarr store is a set of named objects, not a single file. Implementations MUST NOT describe a digest of a directory serialization as the SHA-256 of a Zarr file. + +For a local directory store, `zarr2datatiles` computes `zarr-tree-sha256-v1` as follows: + +1. Initialize SHA-256 with the ASCII/UTF-8 byte string `DataTiles-Zarr-Tree-SHA256-v1` followed by NUL. +2. Recursively enumerate regular files beneath the store root. Reject symbolic links. +3. Sort objects by their relative POSIX key using Unicode code-point order on the UTF-8 path string. +4. For each object, update the digest with `key`, NUL, decimal byte length, NUL, exact object bytes, NUL. +5. Store the lowercase hexadecimal digest with checksum algorithm `zarr-tree-sha256-v1`. + +This digest identifies the complete local object set and its byte content at the time of hashing. For rigorous publication, the source store SHOULD be immutable or snapshot/version identified so hashing and conversion cannot observe different states. + +For a remote store, the utility requires `--source-sha256`. If the access URL contains credentials, user-info, or a signed query string, the operator MUST also supply a stable credential-free `--provenance-uri`; the access URL is used only at runtime and MUST NOT be persisted. That value MUST identify the immutable published store or snapshot according to the provider's archival record. The utility does not traverse an arbitrary remote object store a second time merely to synthesize a checksum: doing so may be unbounded, expensive, authorization-sensitive, and still race with concurrent mutation. Publication workflows SHOULD record an object-version ID, DOI/PID, ETag/version manifest, or repository snapshot identifier in addition to the checksum when available. + +## Metadata and semantic mapping + +For every selected xarray data variable: + +- the source variable name becomes the local DataTiles variable token; +- a valid CF `standard_name`, when present, is the preferred canonical semantic identity; +- source `units` and `long_name` are retained; +- non-spatial dimensions are represented as typed DataTiles dimensions; +- producer-local names without a CF standard name remain in vocabulary `source` and MUST NOT be promoted to CF by inference; +- the native Zarr/xarray variable name is retained as a `Zarr-variable` external identifier. + +Zarr chunk shape, compressor/codec configuration, fill-value representation, and consolidated-metadata status are source-encoding properties. They MAY be retained as provenance attributes but MUST NOT replace scientific semantics. + +## Zarr v2/v3 and fill values + +An importer MAY accept either Zarr format 2 or 3. If `--zarr-format` is supplied, failure to match that format MUST be an error. CF decoding is delegated to xarray. Because Zarr v2 and v3 have different fill-value semantics in the xarray ecosystem, producers SHOULD make `_FillValue`/masking intent explicit in the source and SHOULD test the resulting nodata behavior before publication. + +## Consolidated metadata + +`--consolidated auto` delegates discovery to xarray: consolidated metadata is attempted where supported with fallback according to the backend. `true` requires consolidated metadata; `false` explicitly disables it. The selected policy is recorded in import provenance. Consolidation is a performance/discovery property and MUST NOT be interpreted as stronger scientific provenance. + +## Transformation and output + +The initial profile uses the same declared spatial transformation as the NetCDF/GRIB utilities: Web Mercator target tiles, nearest-neighbour sampling at tile-pixel centers, explicit source/bbox clipping, DNT1 numeric payloads, and a maximum-tile resource guard. The content schema records `source_kind=Zarr` and the resampling/grid parameters. + +A portrayal (PNG/JPEG/WebP, shaded relief, contour image, color ramp) is a downstream reproducible derivation. The importer MUST NOT substitute portrayal pixels for scientific arrays. + +## FAIR, provenance, and rights + +Transport accessibility does not imply permission. The operator MUST provide the source license/terms URI, SPDX expression or explicit `LicenseRef-*`, required attribution, generated-dataset license, and generated-dataset license URI. These are recorded separately. + +The source provenance entity MUST include the original source identifier, checksum algorithm, checksum, and source kind. The conversion activity MUST record zoom, tile size, bbox, Zarr group, requested Zarr format, consolidated-metadata policy, and non-secret storage-option keys. Tile-level lineage links generated tiles to the source entity. + +A DataTiles file created from Zarr is FAIR only to the extent supported by the complete DataTiles FAIR publication profile; successful conversion alone is not a FAIR certification. + +## Standards references + +This profile is designed against the Zarr core specification, with Zarr v3 core specification 3.1 as the current reference at the time of this extension, and the xarray Zarr backend contract. The Zarr core specification defines stores as mappings from keys to bytes and explicitly allows filesystem- and object-store realizations; that store model is the reason DataTiles uses a store-aware digest rather than pretending a Zarr hierarchy is one file. Xarray provides the dataset-level bridge used here and requires dimension/coordinate metadata that it can interpret. + +- Zarr core specification: https://zarr-specs.readthedocs.io/en/latest/v3/core/ +- Xarray `open_zarr`: https://docs.xarray.dev/en/latest/generated/xarray.open_zarr.html +- Xarray Zarr I/O guidance: https://docs.xarray.dev/en/latest/user-guide/io.html#zarr diff --git a/pyproject.toml b/pyproject.toml index fffd9b3..785dbd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "datatiles" -version = "0.10.0" +version = "0.19.0" description = "MBTiles-compatible multidimensional raster-matrix and vector-feature storage" readme = "README.md" requires-python = ">=3.10" @@ -22,6 +22,17 @@ classifiers = [ dependencies = [] [project.optional-dependencies] +integrity = ["cryptography>=45"] +drm = ["cryptography>=45"] +utils = [ + "numpy>=2.0", + "xarray>=2025.1", + "netCDF4>=1.7", + "cfgrib>=0.9.15", + "eccodes>=2.40", + "zarr>=3.0", + "fsspec>=2025.1", +] demo = [ "numpy==2.2.6; python_version < '3.11'", "numpy==2.3.5; python_version >= '3.11'", @@ -34,6 +45,8 @@ test = [ ] [project.scripts] +datatiles-integrity = "datatiles.integrity_cli:main" +datatiles-drm = "datatiles.drm_cli:main" datatiles = "datatiles.cli:main" datatiles-serve = "datatiles.server:main" datatiles-demo = "datatiles.demo:main" diff --git a/src/datatiles/__init__.py b/src/datatiles/__init__.py index 682458f..feff08f 100644 --- a/src/datatiles/__init__.py +++ b/src/datatiles/__init__.py @@ -4,4 +4,4 @@ from .numeric import NumericTile, decode_numeric_tile, encode_numeric_tile __all__ = ["DataTiles", "DataTilesError", "NumericTile", "encode_numeric_tile", "decode_numeric_tile"] -__version__ = "0.10.0" +__version__ = "0.19.0" diff --git a/src/datatiles/cli.py b/src/datatiles/cli.py index 6941180..54a61d0 100644 --- a/src/datatiles/cli.py +++ b/src/datatiles/cli.py @@ -31,6 +31,17 @@ def parser() -> argparse.ArgumentParser: dim.add_argument("--axis", choices=["T","Z","E","C","O"]); dim.add_argument("--unit"); dim.add_argument("--description") dim.add_argument("--optional", action="store_true") dim.add_argument("--extent", choices=["point","interval","point_or_interval"], default="point") + var = sub.add_parser("add-variable", help="register a semantic variable") + var.add_argument("file"); var.add_argument("name"); var.add_argument("--standard-name", required=True) + var.add_argument("--vocabulary", default="CF"); var.add_argument("--vocabulary-version") + var.add_argument("--canonical-unit"); var.add_argument("--long-name"); var.add_argument("--description") + vid = sub.add_parser("add-variable-id", help="add GRIB/SeaDataNet/NVS/etc crosswalk identifier") + vid.add_argument("file"); vid.add_argument("variable"); vid.add_argument("scheme"); vid.add_argument("identifier") + vid.add_argument("--scheme-version"); vid.add_argument("--uri") + vars_cmd = sub.add_parser("variables", help="list semantic variables as JSON"); vars_cmd.add_argument("file") + find_var = sub.add_parser("find-variable", help="find coordinate sets by semantic standard name") + find_var.add_argument("file"); find_var.add_argument("standard_name"); find_var.add_argument("--vocabulary", default="CF") + policy = sub.add_parser("set-variable-policy"); policy.add_argument("file"); policy.add_argument("policy", choices=["required","recommended"]) crs = sub.add_parser("add-crs") crs.add_argument("file"); crs.add_argument("role", choices=["horizontal","vertical","temporal","compound","engineering"]) crs.add_argument("--authority"); crs.add_argument("--code"); crs.add_argument("--uri"); crs.add_argument("--wkt2"); crs.add_argument("--projjson") @@ -52,7 +63,7 @@ def parser() -> argparse.ArgumentParser: contents.add_argument("file") metadata = sub.add_parser("set-metadata", help="set one non-managed metadata value") metadata.add_argument("file"); metadata.add_argument("name"); metadata.add_argument("value") - validate = sub.add_parser("validate"); validate.add_argument("file") + validate = sub.add_parser("validate"); validate.add_argument("file"); validate.add_argument("--cf-table", type=Path); validate.add_argument("--require-variable-semantics", action="store_true") return p @@ -65,6 +76,21 @@ def main(argv: list[str] | None = None) -> int: with DataTiles(args.file) as store: store.add_dimension(args.name, args.type, axis=args.axis, unit=args.unit, description=args.description, required=not args.optional, extent_kind=args.extent) + elif args.command == "add-variable": + with DataTiles(args.file) as store: + store.add_variable(args.name,args.standard_name,vocabulary=args.vocabulary,vocabulary_version=args.vocabulary_version, + canonical_unit=args.canonical_unit,long_name=args.long_name,description=args.description) + elif args.command == "add-variable-id": + with DataTiles(args.file) as store: + store.add_variable_identifier(args.variable,args.scheme,args.identifier,scheme_version=args.scheme_version,uri=args.uri) + elif args.command == "variables": + with DataTiles(args.file) as store: print(json.dumps({"variables":store.variables()},indent=2,ensure_ascii=False)) + elif args.command == "find-variable": + with DataTiles(args.file) as store: + print(json.dumps({"standard_name":args.standard_name,"vocabulary":args.vocabulary, + "coordinate_set_ids":store.find_coordinate_sets_by_standard_name(args.standard_name,vocabulary=args.vocabulary)},indent=2)) + elif args.command == "set-variable-policy": + with DataTiles(args.file) as store: store.set_variable_semantics(args.policy) elif args.command == "add-crs": with DataTiles(args.file) as store: store.add_crs(args.role, authority=args.authority, code=args.code, uri=args.uri, @@ -90,7 +116,7 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "set-metadata": with DataTiles(args.file) as store: store.set_metadata(args.name,args.value) elif args.command == "validate": - with DataTiles(args.file) as store: errors = store.validate() + with DataTiles(args.file) as store: errors = store.validate(cf_table=args.cf_table, require_variable_semantics=args.require_variable_semantics) if errors: for error in errors: print(error) return 1 diff --git a/src/datatiles/drm.py b/src/datatiles/drm.py new file mode 100644 index 0000000..2f4d3a1 --- /dev/null +++ b/src/datatiles/drm.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import struct +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, BinaryIO + +PACKAGE_MAGIC = b"DTPKG1\x00" +PACKAGE_PROFILE = "DataTiles-Protected-Distribution-1" +LICENSE_PROFILE = "DataTiles-Commercial-License-1" +POLICY_PROFILE = "W3C-ODRL-2.2" +CONTENT_ALGORITHM = "AES-256-GCM" +KEY_WRAP_ALGORITHM = "X25519-HKDF-SHA256+A256GCM" +SIGNATURE_ALGORITHM = "Ed25519" +DEFAULT_CHUNK_SIZE = 1024 * 1024 + + +class DRMError(ValueError): + pass + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _require_crypto(): + try: + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + except ImportError as exc: + raise DRMError("DRM support requires the optional 'drm' dependency: pip install 'datatiles[drm]'") from exc + return hashes, serialization, Ed25519PrivateKey, Ed25519PublicKey, X25519PrivateKey, X25519PublicKey, Cipher, algorithms, modes, AESGCM, HKDF + + +def _sha256_file(path: str | Path, chunk_size: int = DEFAULT_CHUNK_SIZE) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def _raw_public_key(public_key) -> bytes: + _, serialization, *_ = _require_crypto() + return public_key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + + +def key_id(public_key) -> str: + return "sha256:" + hashlib.sha256(_raw_public_key(public_key)).hexdigest() + + +def generate_recipient_keypair(private_key_path: str | Path, public_key_path: str | Path, *, overwrite: bool = False) -> dict[str, str]: + _, serialization, _, _, X25519PrivateKey, _, *_ = _require_crypto() + private_path, public_path = Path(private_key_path), Path(public_key_path) + if not overwrite and (private_path.exists() or public_path.exists()): + raise DRMError("refusing to overwrite an existing recipient key file") + private = X25519PrivateKey.generate() + public = private.public_key() + private_path.write_bytes(private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + try: + os.chmod(private_path, 0o600) + except OSError: + pass + public_path.write_bytes(public.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)) + return {"private_key": str(private_path), "public_key": str(public_path), "key_id": key_id(public)} + + +def load_recipient_private_key(path: str | Path): + _, serialization, _, _, X25519PrivateKey, *_ = _require_crypto() + key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None) + if not isinstance(key, X25519PrivateKey): + raise DRMError("recipient private key must be X25519") + return key + + +def load_recipient_public_key(path: str | Path): + _, serialization, _, _, _, X25519PublicKey, *_ = _require_crypto() + key = serialization.load_pem_public_key(Path(path).read_bytes()) + if not isinstance(key, X25519PublicKey): + raise DRMError("recipient public key must be X25519") + return key + + +def load_issuer_private_key(path: str | Path): + _, serialization, Ed25519PrivateKey, *_ = _require_crypto() + key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None) + if not isinstance(key, Ed25519PrivateKey): + raise DRMError("issuer private key must be Ed25519") + return key + + +def load_issuer_public_key(path: str | Path): + _, serialization, _, Ed25519PublicKey, *_ = _require_crypto() + key = serialization.load_pem_public_key(Path(path).read_bytes()) + if not isinstance(key, Ed25519PublicKey): + raise DRMError("issuer public key must be Ed25519") + return key + + +def write_content_key(path: str | Path, key: bytes, *, product_id: str) -> None: + if len(key) != 32: + raise DRMError("content key must be 32 bytes") + p = Path(path) + p.write_text(json.dumps({"profile": PACKAGE_PROFILE, "product_id": product_id, "content_key": _b64(key)}, indent=2) + "\n", encoding="utf-8") + try: + os.chmod(p, 0o600) + except OSError: + pass + + +def read_content_key(path: str | Path, *, expected_product_id: str | None = None) -> tuple[str, bytes]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + product_id = data.get("product_id") + key = _unb64(data.get("content_key", "")) + if len(key) != 32 or not product_id: + raise DRMError("invalid content-key file") + if expected_product_id and product_id != expected_product_id: + raise DRMError("content-key product_id does not match package") + return product_id, key + + +def protect_file(source: str | Path, output: str | Path, *, content_key_file: str | Path, product_id: str | None = None, + issuer: str, issuer_uri: str | None = None, terms_uri: str, license_service_uri: str | None = None, + edition: str | None = None, metadata: dict[str, Any] | None = None, chunk_size: int = DEFAULT_CHUNK_SIZE) -> dict[str, Any]: + *_, Cipher, algorithms, modes, _, _ = _require_crypto() + source, output = Path(source), Path(output) + if not source.is_file(): + raise DRMError("source must be a finalized DataTiles file") + product_id = product_id or f"urn:uuid:{uuid.uuid4()}" + content_key = os.urandom(32) + nonce = os.urandom(12) + plaintext_sha256 = _sha256_file(source, chunk_size) + header = { + "profile": PACKAGE_PROFILE, + "product_id": product_id, + "edition": edition, + "issuer": issuer, + "issuer_uri": issuer_uri, + "terms_uri": terms_uri, + "license_service_uri": license_service_uri, + "content_algorithm": CONTENT_ALGORITHM, + "nonce": _b64(nonce), + "plaintext_sha256": plaintext_sha256, + "plaintext_size": source.stat().st_size, + "created_at": _utc_now(), + "metadata": metadata or {}, + } + header_bytes = _canonical_json(header) + encryptor = Cipher(algorithms.AES(content_key), modes.GCM(nonce)).encryptor() + encryptor.authenticate_additional_data(header_bytes) + cipher_hash = hashlib.sha256() + with source.open("rb") as src, output.open("wb") as dst: + dst.write(PACKAGE_MAGIC); dst.write(struct.pack(">I", len(header_bytes))); dst.write(header_bytes) + while True: + chunk = src.read(chunk_size) + if not chunk: + break + encrypted = encryptor.update(chunk) + dst.write(encrypted); cipher_hash.update(encrypted) + tail = encryptor.finalize() + if tail: + dst.write(tail); cipher_hash.update(tail) + dst.write(encryptor.tag) + write_content_key(content_key_file, content_key, product_id=product_id) + return {**header, "ciphertext_sha256": cipher_hash.hexdigest(), "content_key_file": str(content_key_file), "package": str(output)} + + +def read_package_header(path: str | Path) -> dict[str, Any]: + with open(path, "rb") as f: + if f.read(len(PACKAGE_MAGIC)) != PACKAGE_MAGIC: + raise DRMError("not a DataTiles protected-distribution package") + raw = f.read(4) + if len(raw) != 4: + raise DRMError("truncated package header") + length = struct.unpack(">I", raw)[0] + if length < 2 or length > 1024 * 1024: + raise DRMError("invalid package header length") + header_bytes = f.read(length) + if len(header_bytes) != length: + raise DRMError("truncated package header") + header = json.loads(header_bytes) + if header.get("profile") != PACKAGE_PROFILE or header.get("content_algorithm") != CONTENT_ALGORITHM: + raise DRMError("unsupported protected-distribution profile") + return header + + +def _license_payload(*, package_header: dict[str, Any], recipient_public_key, recipient_id: str | None, content_key: bytes, + issuer: str, issuer_uri: str | None, valid_from: str | None, valid_until: str | None, + permissions: list[str], policy: dict[str, Any] | None) -> dict[str, Any]: + hashes, serialization, _, _, X25519PrivateKey, _, _, _, _, AESGCM, HKDF = _require_crypto() + ephemeral = X25519PrivateKey.generate() + shared = ephemeral.exchange(recipient_public_key) + recipient_key_id = key_id(recipient_public_key) + salt = os.urandom(16) + kek = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=("DataTiles DRM " + package_header["product_id"]).encode()).derive(shared) + wrap_nonce = os.urandom(12) + aad = _canonical_json({"profile": LICENSE_PROFILE, "product_id": package_header["product_id"], "recipient_key_id": recipient_key_id}) + wrapped = AESGCM(kek).encrypt(wrap_nonce, content_key, aad) + ephemeral_raw = ephemeral.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return { + "profile": LICENSE_PROFILE, + "product_id": package_header["product_id"], + "package_plaintext_sha256": package_header["plaintext_sha256"], + "issuer": issuer, + "issuer_uri": issuer_uri, + "recipient_id": recipient_id, + "recipient_key_id": recipient_key_id, + "permissions": sorted(set(permissions)), + "valid_from": valid_from, + "valid_until": valid_until, + "issued_at": _utc_now(), + "key_wrap": { + "algorithm": KEY_WRAP_ALGORITHM, + "ephemeral_public_key": _b64(ephemeral_raw), + "salt": _b64(salt), + "nonce": _b64(wrap_nonce), + "wrapped_content_key": _b64(wrapped), + }, + "odrl": policy or {"@context": "http://www.w3.org/ns/odrl.jsonld", "@type": "Agreement", "target": package_header["product_id"]}, + } + + +def issue_license(package: str | Path, content_key_file: str | Path, recipient_public_key_path: str | Path, + issuer_private_key_path: str | Path, output: str | Path, *, issuer: str, issuer_uri: str | None = None, + recipient_id: str | None = None, valid_from: str | None = None, valid_until: str | None = None, + permissions: list[str] | None = None, policy: dict[str, Any] | None = None) -> dict[str, Any]: + header = read_package_header(package) + _, content_key = read_content_key(content_key_file, expected_product_id=header["product_id"]) + recipient = load_recipient_public_key(recipient_public_key_path) + issuer_key = load_issuer_private_key(issuer_private_key_path) + payload = _license_payload(package_header=header, recipient_public_key=recipient, recipient_id=recipient_id, + content_key=content_key, issuer=issuer, issuer_uri=issuer_uri, valid_from=valid_from, + valid_until=valid_until, permissions=permissions or ["read"], policy=policy) + signature = issuer_key.sign(_canonical_json(payload)) + envelope = { + "payload": payload, + "signature": {"algorithm": SIGNATURE_ALGORITHM, "issuer_key_id": key_id(issuer_key.public_key()), "value": _b64(signature)}, + } + Path(output).write_text(json.dumps(envelope, indent=2, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8") + return envelope + + +def _parse_time(value: str | None): + if value is None: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise DRMError(f"invalid ISO-8601 licence time: {value}") from exc + + +def verify_license(envelope: dict[str, Any], issuer_public_key, *, at_time: datetime | None = None) -> dict[str, Any]: + payload = envelope.get("payload") or {} + sig = envelope.get("signature") or {} + if payload.get("profile") != LICENSE_PROFILE or sig.get("algorithm") != SIGNATURE_ALGORITHM: + raise DRMError("unsupported licence envelope") + issuer_key_id = key_id(issuer_public_key) + key_matches = sig.get("issuer_key_id") == issuer_key_id + crypto_valid = False + if key_matches: + try: + issuer_public_key.verify(_unb64(sig.get("value", "")), _canonical_json(payload)); crypto_valid = True + except Exception: + crypto_valid = False + now = at_time or datetime.now(timezone.utc) + start, end = _parse_time(payload.get("valid_from")), _parse_time(payload.get("valid_until")) + time_valid = (start is None or now >= start) and (end is None or now <= end) + return {"cryptographically_valid": crypto_valid, "issuer_key_matches": key_matches, "time_valid": time_valid, + "valid": crypto_valid and key_matches and time_valid, "product_id": payload.get("product_id"), + "recipient_key_id": payload.get("recipient_key_id"), "permissions": payload.get("permissions", [])} + + +def unwrap_content_key(envelope: dict[str, Any], recipient_private_key, issuer_public_key) -> bytes: + hashes, serialization, _, _, _, X25519PublicKey, _, _, _, AESGCM, HKDF = _require_crypto() + status = verify_license(envelope, issuer_public_key) + if not status["valid"]: + raise DRMError("licence is not valid for the supplied issuer key and current time") + payload = envelope["payload"] + if key_id(recipient_private_key.public_key()) != payload.get("recipient_key_id"): + raise DRMError("licence was issued to a different recipient key") + wrap = payload["key_wrap"] + ephemeral = X25519PublicKey.from_public_bytes(_unb64(wrap["ephemeral_public_key"])) + shared = recipient_private_key.exchange(ephemeral) + kek = HKDF(algorithm=hashes.SHA256(), length=32, salt=_unb64(wrap["salt"]), info=("DataTiles DRM " + payload["product_id"]).encode()).derive(shared) + aad = _canonical_json({"profile": LICENSE_PROFILE, "product_id": payload["product_id"], "recipient_key_id": payload["recipient_key_id"]}) + key = AESGCM(kek).decrypt(_unb64(wrap["nonce"]), _unb64(wrap["wrapped_content_key"]), aad) + if len(key) != 32: + raise DRMError("invalid unwrapped content key") + return key + + +def decrypt_package(package: str | Path, output: str | Path, *, license_path: str | Path, recipient_private_key_path: str | Path, + issuer_public_key_path: str | Path, chunk_size: int = DEFAULT_CHUNK_SIZE) -> dict[str, Any]: + *_, Cipher, algorithms, modes, _, _ = _require_crypto() + envelope = json.loads(Path(license_path).read_text(encoding="utf-8")) + recipient_key = load_recipient_private_key(recipient_private_key_path) + issuer_key = load_issuer_public_key(issuer_public_key_path) + content_key = unwrap_content_key(envelope, recipient_key, issuer_key) + package = Path(package); output = Path(output) + with package.open("rb") as src: + if src.read(len(PACKAGE_MAGIC)) != PACKAGE_MAGIC: + raise DRMError("not a DataTiles protected-distribution package") + length = struct.unpack(">I", src.read(4))[0] + header_bytes = src.read(length); header = json.loads(header_bytes) + if envelope["payload"].get("product_id") != header.get("product_id") or envelope["payload"].get("package_plaintext_sha256") != header.get("plaintext_sha256"): + raise DRMError("licence is bound to a different protected package") + total = package.stat().st_size + cipher_len = total - len(PACKAGE_MAGIC) - 4 - length - 16 + if cipher_len < 0: + raise DRMError("truncated protected package") + src.seek(total - 16); tag = src.read(16) + src.seek(len(PACKAGE_MAGIC) + 4 + length) + decryptor = Cipher(algorithms.AES(content_key), modes.GCM(_unb64(header["nonce"]), tag)).decryptor() + decryptor.authenticate_additional_data(header_bytes) + h = hashlib.sha256(); remaining = cipher_len + try: + with output.open("wb") as dst: + while remaining: + chunk = src.read(min(chunk_size, remaining)) + if not chunk: + raise DRMError("truncated protected package") + remaining -= len(chunk) + plain = decryptor.update(chunk); dst.write(plain); h.update(plain) + tail = decryptor.finalize(); dst.write(tail); h.update(tail) + except Exception: + try: output.unlink() + except FileNotFoundError: pass + raise + digest = h.hexdigest() + if digest != header["plaintext_sha256"]: + try: output.unlink() + except FileNotFoundError: pass + raise DRMError("decrypted product hash does not match protected package identity") + return {"product_id": header["product_id"], "output": str(output), "plaintext_sha256": digest, "permissions": envelope["payload"].get("permissions", [])} diff --git a/src/datatiles/drm_cli.py b/src/datatiles/drm_cli.py new file mode 100644 index 0000000..9a4c6fb --- /dev/null +++ b/src/datatiles/drm_cli.py @@ -0,0 +1,32 @@ +from __future__ import annotations +import argparse, json, sys +from pathlib import Path +from .drm import DRMError, decrypt_package, generate_recipient_keypair, issue_license, load_issuer_public_key, protect_file, read_package_header, verify_license + + +def _dump(v): print(json.dumps(v, indent=2, sort_keys=True, ensure_ascii=False)) + +def main(argv=None): + p=argparse.ArgumentParser(prog="datatiles-drm",description="Optional commercial protected-distribution tooling for DataTiles") + s=p.add_subparsers(dest="command",required=True) + k=s.add_parser("generate-recipient-key",help="generate an X25519 customer/device keypair"); k.add_argument("--private-key",required=True); k.add_argument("--public-key",required=True); k.add_argument("--overwrite",action="store_true") + x=s.add_parser("protect",help="encrypt a finalized DataTiles object into a .dtpkg package"); x.add_argument("source"); x.add_argument("output"); x.add_argument("--content-key-file",required=True); x.add_argument("--product-id"); x.add_argument("--issuer",required=True); x.add_argument("--issuer-uri"); x.add_argument("--terms-uri",required=True); x.add_argument("--license-service-uri"); x.add_argument("--edition") + i=s.add_parser("inspect",help="inspect the public package header"); i.add_argument("package") + l=s.add_parser("issue-license",help="issue an issuer-signed recipient licence and wrapped content key"); l.add_argument("package"); l.add_argument("--content-key-file",required=True); l.add_argument("--recipient-public-key",required=True); l.add_argument("--issuer-private-key",required=True); l.add_argument("--output",required=True); l.add_argument("--issuer",required=True); l.add_argument("--issuer-uri"); l.add_argument("--recipient-id"); l.add_argument("--valid-from"); l.add_argument("--valid-until"); l.add_argument("--permission",action="append",default=[]); l.add_argument("--odrl-policy") + v=s.add_parser("verify-license",help="verify issuer signature and licence time bounds"); v.add_argument("license"); v.add_argument("--issuer-public-key",required=True) + d=s.add_parser("decrypt",help="decrypt an authorized protected package"); d.add_argument("package"); d.add_argument("output"); d.add_argument("--license",required=True); d.add_argument("--recipient-private-key",required=True); d.add_argument("--issuer-public-key",required=True) + a=p.parse_args(argv) + try: + if a.command=="generate-recipient-key": _dump(generate_recipient_keypair(a.private_key,a.public_key,overwrite=a.overwrite)); return 0 + if a.command=="protect": _dump(protect_file(a.source,a.output,content_key_file=a.content_key_file,product_id=a.product_id,issuer=a.issuer,issuer_uri=a.issuer_uri,terms_uri=a.terms_uri,license_service_uri=a.license_service_uri,edition=a.edition)); return 0 + if a.command=="inspect": _dump(read_package_header(a.package)); return 0 + if a.command=="issue-license": + policy=json.loads(Path(a.odrl_policy).read_text()) if a.odrl_policy else None + env=issue_license(a.package,a.content_key_file,a.recipient_public_key,a.issuer_private_key,a.output,issuer=a.issuer,issuer_uri=a.issuer_uri,recipient_id=a.recipient_id,valid_from=a.valid_from,valid_until=a.valid_until,permissions=a.permission or ["read"],policy=policy); _dump({"output":a.output,"product_id":env["payload"]["product_id"],"recipient_key_id":env["payload"]["recipient_key_id"],"issuer_key_id":env["signature"]["issuer_key_id"]}); return 0 + if a.command=="verify-license": + env=json.loads(Path(a.license).read_text()); _dump(verify_license(env,load_issuer_public_key(a.issuer_public_key))); return 0 + if a.command=="decrypt": _dump(decrypt_package(a.package,a.output,license_path=a.license,recipient_private_key_path=a.recipient_private_key,issuer_public_key_path=a.issuer_public_key)); return 0 + except (DRMError,OSError,ValueError,json.JSONDecodeError) as e: + print(f"datatiles-drm: {e}",file=sys.stderr); return 2 + return 2 +if __name__=="__main__": raise SystemExit(main()) diff --git a/src/datatiles/fair.py b/src/datatiles/fair.py new file mode 100644 index 0000000..ce72c8e --- /dev/null +++ b/src/datatiles/fair.py @@ -0,0 +1,89 @@ +"""FAIR, provenance, rights, and citation helpers for DataTiles. + +The module is deliberately dependency-free. It validates the conservative lexical +subset of SPDX expressions needed at the container boundary; authoritative SPDX +list membership remains a publication/CI check against a pinned SPDX License List. +""" +from __future__ import annotations + +import json +import re +from typing import Any, Mapping + +SPDX_TOKEN = re.compile(r"(?:DocumentRef-[A-Za-z0-9.-]+:)?LicenseRef-[A-Za-z0-9.-]+|[A-Za-z0-9.-]+\+?") +SPDX_OPERATORS = {"AND", "OR", "WITH"} +PID_SCHEMES = {"DOI", "Handle", "ARK", "URN", "SWHID", "RAiD", "URL"} +ACCESS_RIGHTS = {"open", "embargoed", "restricted", "closed"} +RIGHTS_SCOPES = {"dataset", "metadata", "source", "portrayal", "software", "other"} + +class FairValidationError(ValueError): + pass + + +def validate_spdx_expression(expression: str) -> str: + """Reject malformed/ambiguous SPDX-like expressions without network access.""" + if not isinstance(expression, str) or not expression.strip() or expression != expression.strip(): + raise FairValidationError("SPDX expression must be non-empty and trimmed") + tokens = re.findall(r"\(|\)|[A-Za-z0-9.+:-]+", expression) + if "".join(tokens).replace("(", "").replace(")", "") == "": + raise FairValidationError("empty SPDX expression") + # Ensure no unparsed punctuation/whitespace ambiguity. + rebuilt = " ".join(tokens).replace("( ", "(").replace(" )", ")") + normalized = re.sub(r"\s+", " ", expression).replace("( ", "(").replace(" )", ")") + if rebuilt != normalized: + raise FairValidationError("invalid SPDX expression syntax") + depth = 0 + expect_operand = True + for token in tokens: + if token == "(": + if not expect_operand: raise FairValidationError("missing SPDX operator") + depth += 1 + elif token == ")": + if expect_operand or depth == 0: raise FairValidationError("unbalanced SPDX parentheses") + depth -= 1; expect_operand = False + elif token in SPDX_OPERATORS: + if expect_operand: raise FairValidationError("misplaced SPDX operator") + expect_operand = True + else: + if not SPDX_TOKEN.fullmatch(token): raise FairValidationError(f"invalid SPDX token: {token}") + if not expect_operand: raise FairValidationError("missing SPDX operator") + expect_operand = False + if depth or expect_operand: raise FairValidationError("incomplete SPDX expression") + return expression + + +def datacite_metadata(store: Any) -> dict[str, Any]: + """Produce a DataCite-4.7-shaped publication record from explicit container facts. + + This is an export helper, not a claim that a DOI has been registered. + """ + meta = store.metadata() + primary = store.primary_identifier() + creators = store.fair_agents(role="creator") + rights = store.rights() + related = store.related_identifiers() + result: dict[str, Any] = { + "schemaVersion": "http://datacite.org/schema/kernel-4", + "titles": [{"title": meta.get("name", store.path.stem)}], + "publisher": meta.get("datatiles:publisher", "UNSPECIFIED"), + "publicationYear": meta.get("datatiles:publication_year", "UNSPECIFIED"), + "types": {"resourceTypeGeneral": "Dataset", "resourceType": "DataTiles multidimensional tiled dataset"}, + "creators": creators, + "rightsList": [{"rights": r["license_expression"], "rightsUri": r.get("license_uri")} for r in rights if r["scope"] in ("dataset","metadata")], + "relatedIdentifiers": related, + } + if primary: + result["identifier"] = {"identifier": primary["identifier"], "identifierType": primary["scheme"]} + return result + + +def prov_json(store: Any) -> dict[str, Any]: + """Export the internal graph with explicit W3C PROV term mappings.""" + entities = {r["entity_id"]: {"prov:type": r["entity_type"], "prov:label": r["label"], **({"prov:location": r["uri"]} if r["uri"] else {})} + for r in store.db.execute("SELECT * FROM datatiles_provenance_entities")} + activities = {r["activity_id"]: {"prov:type": r["activity_type"], "prov:label": r["label"], "prov:startedAtTime": r["started_at"], "prov:endedAtTime": r["ended_at"]} + for r in store.db.execute("SELECT * FROM datatiles_provenance_activities")} + agents = {r["agent_id"]: {"prov:type": r["agent_type"], "prov:label": r["label"], **({"prov:location": r["uri"]} if r["uri"] else {})} + for r in store.db.execute("SELECT * FROM datatiles_provenance_agents")} + relations = [dict(r) for r in store.db.execute("SELECT * FROM datatiles_provenance_relations ORDER BY subject_id,predicate,object_id")] + return {"prefix": {"prov": "http://www.w3.org/ns/prov#"}, "entity": entities, "activity": activities, "agent": agents, "relations": relations} diff --git a/src/datatiles/integrity.py b/src/datatiles/integrity.py new file mode 100644 index 0000000..70453f3 --- /dev/null +++ b/src/datatiles/integrity.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import base64 +import hashlib +import heapq +import json +import os +import sqlite3 +import struct +import tempfile +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +INTEGRITY_PROFILE = "DataTiles-Integrity-Manifest-1" +CANONICALIZATION = "datatiles-sqlite-logical-v1" +HASH_ALGORITHM = "sha256" +PAYLOAD_TYPE = "application/vnd.openfairwind.datatiles.integrity-manifest.v1+json" +EXCLUDED_TABLES = {"datatiles_integrity_manifests", "datatiles_signatures"} + + +class IntegrityError(ValueError): + pass + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def _normalise_sql(sql: str | None) -> str: + return " ".join((sql or "").strip().split()) + + +def _typed_value(value: Any) -> list[str]: + if value is None: + return ["null", ""] + if isinstance(value, bool): + return ["integer", "1" if value else "0"] + if isinstance(value, int): + return ["integer", str(value)] + if isinstance(value, float): + return ["real64be", struct.pack(">d", value).hex()] + if isinstance(value, (bytes, bytearray, memoryview)): + return ["blob", base64.b64encode(bytes(value)).decode("ascii")] + if isinstance(value, str): + return ["text", value] + raise IntegrityError(f"unsupported SQLite value type: {type(value).__name__}") + + +def _row_digest(row: Iterable[Any]) -> bytes: + return hashlib.sha256(_canonical_json([_typed_value(v) for v in row])).digest() + + +def _digest_sorted_rows(rows: Iterable[Iterable[Any]], *, chunk_size: int = 100_000) -> tuple[int, str]: + """Digest a table as a deterministic multiset of row digests with bounded RAM. + + Row order in SQLite is intentionally ignored. Digests are externally sorted in + temporary files and then streamed into the table digest, preserving duplicate + multiplicity while avoiding an unbounded in-memory list for large tile tables. + """ + if chunk_size < 1: + raise IntegrityError("chunk_size must be positive") + count = 0 + chunk: list[bytes] = [] + paths: list[str] = [] + try: + for row in rows: + count += 1 + chunk.append(_row_digest(row)) + if len(chunk) >= chunk_size: + chunk.sort() + f = tempfile.NamedTemporaryFile(prefix="datatiles-integrity-", suffix=".digests", delete=False) + with f: + for digest in chunk: + f.write(digest.hex().encode("ascii") + b"\n") + paths.append(f.name) + chunk.clear() + + if not paths: + chunk.sort() + h = hashlib.sha256() + for digest in chunk: + h.update(digest) + return count, h.hexdigest() + + if chunk: + chunk.sort() + f = tempfile.NamedTemporaryFile(prefix="datatiles-integrity-", suffix=".digests", delete=False) + with f: + for digest in chunk: + f.write(digest.hex().encode("ascii") + b"\n") + paths.append(f.name) + + handles = [open(path, "rt", encoding="ascii") for path in paths] + try: + h = hashlib.sha256() + for line in heapq.merge(*handles): + h.update(bytes.fromhex(line.strip())) + return count, h.hexdigest() + finally: + for handle in handles: + handle.close() + finally: + for path in paths: + try: + os.unlink(path) + except FileNotFoundError: + pass + + +def _schema_objects(db: sqlite3.Connection) -> list[dict[str, str]]: + rows = db.execute( + "SELECT type,name,tbl_name,sql FROM sqlite_master " + "WHERE type IN ('table','view','trigger') AND name NOT LIKE 'sqlite_%' ORDER BY type,name" + ) + result = [] + for object_type, name, table_name, sql in rows: + if name in EXCLUDED_TABLES or table_name in EXCLUDED_TABLES: + continue + result.append({ + "type": object_type, + "name": name, + "table": table_name, + "sql": _normalise_sql(sql), + }) + return result + + +def build_manifest(db: sqlite3.Connection, *, chunk_size: int = 100_000) -> dict[str, Any]: + previous_row_factory = db.row_factory + try: + db.row_factory = None + schema_revision = int(db.execute("PRAGMA user_version").fetchone()[0]) + objects = _schema_objects(db) + tables = [] + for obj in objects: + if obj["type"] != "table": + continue + table = obj["name"] + quoted = '"' + table.replace('"', '""') + '"' + cursor = db.execute(f"SELECT * FROM {quoted}") + row_count, rows_sha256 = _digest_sorted_rows(cursor, chunk_size=chunk_size) + tables.append({ + "name": table, + "schema_sha256": hashlib.sha256(obj["sql"].encode("utf-8")).hexdigest(), + "row_count": row_count, + "rows_sha256": rows_sha256, + }) + + views_and_triggers = [ + { + "type": obj["type"], + "name": obj["name"], + "schema_sha256": hashlib.sha256(obj["sql"].encode("utf-8")).hexdigest(), + } + for obj in objects if obj["type"] != "table" + ] + body = { + "profile": INTEGRITY_PROFILE, + "payload_type": PAYLOAD_TYPE, + "canonicalization": CANONICALIZATION, + "hash_algorithm": HASH_ALGORITHM, + "schema_revision": schema_revision, + "tables": sorted(tables, key=lambda x: x["name"]), + "views_and_triggers": sorted(views_and_triggers, key=lambda x: (x["type"], x["name"])), + } + root = hashlib.sha256(_canonical_json(body)).hexdigest() + return {**body, "root_sha256": root} + finally: + db.row_factory = previous_row_factory + + +def manifest_bytes(manifest: dict[str, Any]) -> bytes: + required = {"profile", "payload_type", "canonicalization", "hash_algorithm", "schema_revision", "tables", "views_and_triggers", "root_sha256"} + missing = required - manifest.keys() + if missing: + raise IntegrityError("manifest missing fields: " + ", ".join(sorted(missing))) + body = {k: manifest[k] for k in manifest if k != "root_sha256"} + expected = hashlib.sha256(_canonical_json(body)).hexdigest() + if expected != manifest["root_sha256"]: + raise IntegrityError("manifest root_sha256 does not match canonical manifest body") + return _canonical_json(manifest) + + +def key_id_from_public_bytes(public_key_raw: bytes) -> str: + return "sha256:" + hashlib.sha256(public_key_raw).hexdigest() + + +def _require_crypto(): + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + except ImportError as exc: + raise IntegrityError("digital signatures require the optional 'integrity' dependency: pip install 'datatiles[integrity]'") from exc + return serialization, Ed25519PrivateKey, Ed25519PublicKey + + +def generate_ed25519_keypair(private_key_path: str | Path, public_key_path: str | Path, *, overwrite: bool = False) -> tuple[str, str]: + serialization, Ed25519PrivateKey, _ = _require_crypto() + private_path, public_path = Path(private_key_path), Path(public_key_path) + if not overwrite and (private_path.exists() or public_path.exists()): + raise IntegrityError("refusing to overwrite an existing key file") + private = Ed25519PrivateKey.generate() + public = private.public_key() + private_pem = private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + public_pem = public.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + private_path.write_bytes(private_pem) + try: + os.chmod(private_path, 0o600) + except OSError: + pass + public_path.write_bytes(public_pem) + raw_public = public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return str(private_path), key_id_from_public_bytes(raw_public) + + +def load_private_key(path: str | Path): + serialization, Ed25519PrivateKey, _ = _require_crypto() + key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None) + if not isinstance(key, Ed25519PrivateKey): + raise IntegrityError("only Ed25519 private keys are supported by the native DataTiles signature profile") + return key + + +def load_public_key(path: str | Path): + serialization, _, Ed25519PublicKey = _require_crypto() + key = serialization.load_pem_public_key(Path(path).read_bytes()) + if not isinstance(key, Ed25519PublicKey): + raise IntegrityError("only Ed25519 public keys are supported by the native DataTiles signature profile") + return key + + +def create_signature_envelope(manifest: dict[str, Any], private_key, *, signer: str | None = None, signed_at: str | None = None) -> dict[str, Any]: + serialization, Ed25519PrivateKey, _ = _require_crypto() + if not isinstance(private_key, Ed25519PrivateKey): + raise IntegrityError("native signing requires an Ed25519 private key") + payload = manifest_bytes(manifest) + signature = private_key.sign(payload) + raw_public = private_key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return { + "profile": "DataTiles-Ed25519-Signature-1", + "payload_type": PAYLOAD_TYPE, + "manifest": manifest, + "signature": { + "scheme": "Ed25519", + "encoding": "base64", + "value": base64.b64encode(signature).decode("ascii"), + "key_id": key_id_from_public_bytes(raw_public), + "public_key_raw_base64": base64.b64encode(raw_public).decode("ascii"), + "signed_at": signed_at or _utc_now(), + **({"signer": signer} if signer else {}), + }, + } + + +def verify_signature_envelope(envelope: dict[str, Any], *, public_key=None, expected_key_id: str | None = None) -> dict[str, Any]: + serialization, _, Ed25519PublicKey = _require_crypto() + if envelope.get("profile") != "DataTiles-Ed25519-Signature-1": + raise IntegrityError("unsupported signature envelope profile") + sig = envelope.get("signature") or {} + if sig.get("scheme") != "Ed25519" or sig.get("encoding") != "base64": + raise IntegrityError("unsupported signature scheme/encoding") + manifest = envelope.get("manifest") + if not isinstance(manifest, dict): + raise IntegrityError("signature envelope has no manifest") + payload = manifest_bytes(manifest) + embedded_raw = base64.b64decode(sig.get("public_key_raw_base64", ""), validate=True) + embedded_key_id = key_id_from_public_bytes(embedded_raw) + if embedded_key_id != sig.get("key_id"): + raise IntegrityError("embedded public key does not match signature key_id") + if expected_key_id and embedded_key_id != expected_key_id: + raise IntegrityError("signature key_id does not match the required key") + trusted = public_key is not None + if public_key is None: + public_key = Ed25519PublicKey.from_public_bytes(embedded_raw) + else: + supplied_raw = public_key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + if supplied_raw != embedded_raw: + raise IntegrityError("supplied trusted public key does not match the embedded signature key") + try: + public_key.verify(base64.b64decode(sig["value"], validate=True), payload) + except Exception as exc: + raise IntegrityError("digital signature verification failed") from exc + return { + "cryptographically_valid": True, + "trusted_key_supplied": trusted, + "key_id": embedded_key_id, + "root_sha256": manifest["root_sha256"], + "signed_at": sig.get("signed_at"), + "signer": sig.get("signer"), + } + + +def verify_database_against_envelope(db: sqlite3.Connection, envelope: dict[str, Any], *, public_key=None, expected_key_id: str | None = None, chunk_size: int = 100_000) -> dict[str, Any]: + signature_result = verify_signature_envelope(envelope, public_key=public_key, expected_key_id=expected_key_id) + current = build_manifest(db, chunk_size=chunk_size) + expected = envelope["manifest"] + content_match = current["root_sha256"] == expected["root_sha256"] + return { + **signature_result, + "content_matches_signed_manifest": content_match, + "current_root_sha256": current["root_sha256"], + "signed_root_sha256": expected["root_sha256"], + "valid": bool(signature_result["cryptographically_valid"] and content_match), + } + + +def store_envelope(db: sqlite3.Connection, envelope: dict[str, Any], *, signer_agent_id: str | None = None, verification_material: dict[str, Any] | None = None) -> str: + manifest = envelope["manifest"] + sig = envelope["signature"] + manifest_id = "urn:uuid:" + str(uuid.uuid4()) + signature_id = "urn:uuid:" + str(uuid.uuid4()) + manifest_json = _canonical_json(manifest).decode("utf-8") + created_at = sig.get("signed_at") or _utc_now() + with db: + row = db.execute("SELECT manifest_id FROM datatiles_integrity_manifests WHERE root_sha256=?", (manifest["root_sha256"],)).fetchone() + if row: + manifest_id = row[0] + else: + db.execute( + "INSERT INTO datatiles_integrity_manifests(manifest_id,profile,canonicalization,hash_algorithm,root_sha256,manifest_json,created_at) VALUES (?,?,?,?,?,?,?)", + (manifest_id, manifest["profile"], manifest["canonicalization"], manifest["hash_algorithm"], manifest["root_sha256"], manifest_json, created_at), + ) + db.execute( + "INSERT INTO datatiles_signatures(signature_id,manifest_id,signature_scheme,signature_encoding,signature,key_id,public_key,signer_agent_id,signed_at,verification_material_json) VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + signature_id, manifest_id, sig["scheme"], sig["encoding"], base64.b64decode(sig["value"]), sig["key_id"], + base64.b64decode(sig["public_key_raw_base64"]), signer_agent_id, sig.get("signed_at") or _utc_now(), + json.dumps(verification_material, sort_keys=True, separators=(",", ":")) if verification_material else None, + ), + ) + return signature_id + + +def list_stored_signatures(db: sqlite3.Connection) -> list[dict[str, Any]]: + rows = db.execute( + "SELECT s.signature_id,s.signature_scheme,s.signature_encoding,s.key_id,s.signer_agent_id,s.signed_at,m.manifest_id,m.profile,m.root_sha256,s.verification_material_json " + "FROM datatiles_signatures s JOIN datatiles_integrity_manifests m USING(manifest_id) ORDER BY s.signed_at,s.signature_id" + ) + result = [] + for row in rows: + item = { + "signature_id": row[0], "scheme": row[1], "encoding": row[2], "key_id": row[3], + "signer_agent_id": row[4], "signed_at": row[5], "manifest_id": row[6], + "manifest_profile": row[7], "root_sha256": row[8], + } + if row[9]: + item["verification_material"] = json.loads(row[9]) + result.append(item) + return result + + +def envelope_from_stored_signature(db: sqlite3.Connection, signature_id: str) -> dict[str, Any]: + row = db.execute( + "SELECT m.manifest_json,s.signature_scheme,s.signature_encoding,s.signature,s.key_id,s.public_key,s.signed_at,s.signer_agent_id " + "FROM datatiles_signatures s JOIN datatiles_integrity_manifests m USING(manifest_id) WHERE s.signature_id=?", + (signature_id,), + ).fetchone() + if row is None: + raise IntegrityError(f"unknown signature: {signature_id}") + manifest = json.loads(row[0]) + return { + "profile": "DataTiles-Ed25519-Signature-1", + "payload_type": PAYLOAD_TYPE, + "manifest": manifest, + "signature": { + "scheme": row[1], "encoding": row[2], "value": base64.b64encode(row[3]).decode("ascii"), + "key_id": row[4], "public_key_raw_base64": base64.b64encode(row[5]).decode("ascii"), + "signed_at": row[6], **({"signer_agent_id": row[7]} if row[7] else {}), + }, + } diff --git a/src/datatiles/integrity_cli.py b/src/datatiles/integrity_cli.py new file mode 100644 index 0000000..22e6f3b --- /dev/null +++ b/src/datatiles/integrity_cli.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from pathlib import Path + +from .integrity import ( + IntegrityError, build_manifest, create_signature_envelope, envelope_from_stored_signature, + generate_ed25519_keypair, list_stored_signatures, load_private_key, load_public_key, + store_envelope, verify_database_against_envelope, +) + + +def _db(path: str, *, writable: bool = False) -> sqlite3.Connection: + if writable: + db = sqlite3.connect(path) + else: + db = sqlite3.connect(f"file:{Path(path).resolve()}?mode=ro", uri=True) + db.execute("PRAGMA foreign_keys=ON") + return db + + +def _dump(value, output: str | None = None): + text = json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + if output: + Path(output).write_text(text, encoding="utf-8") + else: + sys.stdout.write(text) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="datatiles-integrity", description="Create and verify DataTiles integrity manifests and optional Ed25519 signatures") + sub = p.add_subparsers(dest="command", required=True) + + g = sub.add_parser("generate-key", help="generate an Ed25519 signing keypair") + g.add_argument("--private-key", required=True); g.add_argument("--public-key", required=True); g.add_argument("--overwrite", action="store_true") + + m = sub.add_parser("manifest", help="compute the canonical logical integrity manifest") + m.add_argument("file"); m.add_argument("--output"); m.add_argument("--chunk-size", type=int, default=100000) + + s = sub.add_parser("sign", help="sign the current canonical manifest") + s.add_argument("file"); s.add_argument("--private-key", required=True); s.add_argument("--signer"); s.add_argument("--signer-agent-id") + s.add_argument("--detached", help="write a detached signature envelope JSON") + s.add_argument("--no-store", action="store_true", help="do not store the signature inside the DataTiles container") + s.add_argument("--chunk-size", type=int, default=100000) + + v = sub.add_parser("verify", help="verify a stored or detached signature and current DataTiles content") + v.add_argument("file"); v.add_argument("--signature-id"); v.add_argument("--detached"); v.add_argument("--public-key") + v.add_argument("--require-key-id"); v.add_argument("--chunk-size", type=int, default=100000) + + l = sub.add_parser("list", help="list signatures stored in a DataTiles container") + l.add_argument("file") + + args = p.parse_args(argv) + try: + if args.command == "generate-key": + private_path, key_id = generate_ed25519_keypair(args.private_key, args.public_key, overwrite=args.overwrite) + _dump({"private_key": private_path, "public_key": args.public_key, "key_id": key_id}); return 0 + if args.command == "manifest": + with _db(args.file) as db: manifest = build_manifest(db, chunk_size=args.chunk_size) + _dump(manifest, args.output); return 0 + if args.command == "sign": + key = load_private_key(args.private_key) + with _db(args.file, writable=not args.no_store) as db: + manifest = build_manifest(db, chunk_size=args.chunk_size) + envelope = create_signature_envelope(manifest, key, signer=args.signer) + signature_id = None + if not args.no_store: + signature_id = store_envelope(db, envelope, signer_agent_id=args.signer_agent_id) + if args.detached: _dump(envelope, args.detached) + _dump({"signature_id": signature_id, "root_sha256": manifest["root_sha256"], "key_id": envelope["signature"]["key_id"], "detached": args.detached}); return 0 + if args.command == "list": + with _db(args.file) as db: result = list_stored_signatures(db) + _dump({"signatures": result}); return 0 + if args.command == "verify": + key = load_public_key(args.public_key) if args.public_key else None + with _db(args.file) as db: + if args.detached: + envelope = json.loads(Path(args.detached).read_text(encoding="utf-8")) + else: + signature_id = args.signature_id + if not signature_id: + rows = list_stored_signatures(db) + if len(rows) != 1: + raise IntegrityError("specify --signature-id when the container does not contain exactly one signature") + signature_id = rows[0]["signature_id"] + envelope = envelope_from_stored_signature(db, signature_id) + result = verify_database_against_envelope(db, envelope, public_key=key, expected_key_id=args.require_key_id, chunk_size=args.chunk_size) + _dump(result); return 0 if result["valid"] else 2 + except (IntegrityError, sqlite3.Error, OSError, ValueError) as exc: + print(f"datatiles-integrity: {exc}", file=sys.stderr) + return 2 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/datatiles/migration_v5_to_v6.sql b/src/datatiles/migration_v5_to_v6.sql new file mode 100644 index 0000000..02850c8 --- /dev/null +++ b/src/datatiles/migration_v5_to_v6.sql @@ -0,0 +1,29 @@ +CREATE TABLE datatiles_integrity_manifests ( + manifest_id TEXT PRIMARY KEY, + profile TEXT NOT NULL, + canonicalization TEXT NOT NULL, + hash_algorithm TEXT NOT NULL CHECK(hash_algorithm = 'sha256'), + root_sha256 TEXT NOT NULL UNIQUE CHECK(length(root_sha256) = 64), + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL +) WITHOUT ROWID; + +CREATE TABLE datatiles_signatures ( + signature_id TEXT PRIMARY KEY, + manifest_id TEXT NOT NULL REFERENCES datatiles_integrity_manifests(manifest_id) ON DELETE CASCADE, + signature_scheme TEXT NOT NULL, + signature_encoding TEXT NOT NULL, + signature BLOB NOT NULL, + key_id TEXT NOT NULL, + public_key BLOB, + signer_agent_id TEXT REFERENCES datatiles_provenance_agents(agent_id) ON DELETE SET NULL, + signed_at TEXT NOT NULL, + verification_material_json TEXT, + UNIQUE(manifest_id, signature_scheme, key_id, signature) +) WITHOUT ROWID; +CREATE INDEX datatiles_signatures_manifest ON datatiles_signatures(manifest_id); +CREATE INDEX datatiles_signatures_key ON datatiles_signatures(key_id); + +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:integrity_profile','DataTiles-Integrity-Manifest-1'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:signature_profile','DataTiles-Ed25519-Signature-1'); +PRAGMA user_version = 6; diff --git a/src/datatiles/migration_v6_to_v7.sql b/src/datatiles/migration_v6_to_v7.sql new file mode 100644 index 0000000..5f24756 --- /dev/null +++ b/src/datatiles/migration_v6_to_v7.sql @@ -0,0 +1,24 @@ +CREATE TABLE datatiles_commercial_products ( + product_id TEXT PRIMARY KEY, + edition TEXT, + issuer TEXT NOT NULL, + issuer_uri TEXT, + terms_uri TEXT NOT NULL, + license_service_uri TEXT, + protection_profile TEXT NOT NULL DEFAULT 'DataTiles-Protected-Distribution-1', + created_at TEXT NOT NULL, + metadata_json TEXT +) WITHOUT ROWID; + +CREATE TABLE datatiles_drm_policies ( + product_id TEXT NOT NULL REFERENCES datatiles_commercial_products(product_id) ON DELETE CASCADE, + policy_id TEXT NOT NULL, + policy_profile TEXT NOT NULL DEFAULT 'W3C-ODRL-2.2', + policy_json TEXT NOT NULL, + PRIMARY KEY(product_id, policy_id) +) WITHOUT ROWID; +CREATE INDEX datatiles_drm_policy_product ON datatiles_drm_policies(product_id); + +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:drm_profile','DataTiles-Protected-Distribution-1'); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:rights_policy_profile','W3C-ODRL-2.2'); +PRAGMA user_version = 7; diff --git a/src/datatiles/migration_v7_to_v8.sql b/src/datatiles/migration_v7_to_v8.sql new file mode 100644 index 0000000..2d8386a --- /dev/null +++ b/src/datatiles/migration_v7_to_v8.sql @@ -0,0 +1,16 @@ +CREATE TABLE datatiles_release ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + product_id TEXT NOT NULL, + version TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence >= 1), + released_at TEXT NOT NULL, + previous_version TEXT, + previous_identifier TEXT, + release_notes_uri TEXT, + update_uri TEXT, + CHECK(product_id <> '' AND trim(product_id) = product_id), + CHECK(version <> '' AND trim(version) = version) +); +CREATE UNIQUE INDEX datatiles_release_product_version ON datatiles_release(product_id, version); +INSERT OR IGNORE INTO metadata(name,value) VALUES ('datatiles:versioning_profile','DataTiles-Release-Versioning-1'); +PRAGMA user_version = 8; diff --git a/src/datatiles/profile-demo.html b/src/datatiles/profile-demo.html index 91903f5..d7595df 100644 --- a/src/datatiles/profile-demo.html +++ b/src/datatiles/profile-demo.html @@ -2,6 +2,7 @@
datatiles-serve <generated-file> --port 8080, then open http://127.0.0.1:8080/playground. Do not open profile-demo.html with a file:// URL.