mostlyright-data 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
"""Crash-safe protected credential envelopes and local store coordination.
|
|
2
|
+
|
|
3
|
+
Raw device credentials are permitted only in :class:`ProtectedCredential` values and backend
|
|
4
|
+
payloads. Every representation and every error in this module is deliberately allowlisted; do
|
|
5
|
+
not replace the explicit encoders with generic dataclass serialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol, Self
|
|
19
|
+
|
|
20
|
+
from mostlyright.data_harness.canonical import (
|
|
21
|
+
CanonicalJSONError,
|
|
22
|
+
canonical_json_bytes,
|
|
23
|
+
parse_canonical_json,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CREDENTIAL_ENVELOPE_SCHEMA = "mr-data-credentials.v2"
|
|
27
|
+
MAX_CREDENTIAL_ENVELOPE_BYTES = 64 * 1024
|
|
28
|
+
SECURE_FILE_NAME = "credentials.secure"
|
|
29
|
+
LOCK_FILE_NAME = ".credential.lock"
|
|
30
|
+
|
|
31
|
+
_STATES = frozenset(
|
|
32
|
+
{"active", "login_pending", "revoke_pending", "cleanup_pending", "logout_pending"}
|
|
33
|
+
)
|
|
34
|
+
_OPERATIONS = frozenset({"login", "rotate"})
|
|
35
|
+
_CREDENTIAL_FIELDS = frozenset(
|
|
36
|
+
{"cloud_url", "key_id", "prefix", "last_four", "device_name", "created_at", "raw_key"}
|
|
37
|
+
)
|
|
38
|
+
_LEASE_FIELDS = frozenset(
|
|
39
|
+
{"nonce", "base_generation", "operation", "requested_device_name", "created_at"}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CredentialStoreError(RuntimeError):
|
|
44
|
+
"""A typed, secret-free protected-store refusal."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
47
|
+
self.code = code
|
|
48
|
+
self.detail = detail
|
|
49
|
+
super().__init__(f"{code}: {detail}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ProtectedCredential:
|
|
54
|
+
"""One secret-bearing device credential with an always-redacted representation."""
|
|
55
|
+
|
|
56
|
+
cloud_url: str
|
|
57
|
+
key_id: str
|
|
58
|
+
prefix: str
|
|
59
|
+
last_four: str
|
|
60
|
+
device_name: str
|
|
61
|
+
created_at: str
|
|
62
|
+
raw_key: str
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if any(
|
|
66
|
+
not isinstance(getattr(self, field), str) or not getattr(self, field)
|
|
67
|
+
for field in _CREDENTIAL_FIELDS
|
|
68
|
+
):
|
|
69
|
+
raise CredentialStoreError(
|
|
70
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential has an invalid field"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return (
|
|
75
|
+
"ProtectedCredential("
|
|
76
|
+
f"cloud_url={self.cloud_url!r}, key_id={self.key_id!r}, prefix={self.prefix!r}, "
|
|
77
|
+
f"last_four={self.last_four!r}, device_name={self.device_name!r}, "
|
|
78
|
+
f"created_at={self.created_at!r}, raw_key='<redacted>')"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _payload(self) -> dict[str, str]:
|
|
82
|
+
return {
|
|
83
|
+
"cloud_url": self.cloud_url,
|
|
84
|
+
"key_id": self.key_id,
|
|
85
|
+
"prefix": self.prefix,
|
|
86
|
+
"last_four": self.last_four,
|
|
87
|
+
"device_name": self.device_name,
|
|
88
|
+
"created_at": self.created_at,
|
|
89
|
+
"raw_key": self.raw_key,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def _from_payload(cls, value: object) -> Self:
|
|
94
|
+
if not isinstance(value, dict) or set(value) != _CREDENTIAL_FIELDS:
|
|
95
|
+
raise _corrupt()
|
|
96
|
+
if any(
|
|
97
|
+
not isinstance(value[field], str) or not value[field] for field in _CREDENTIAL_FIELDS
|
|
98
|
+
):
|
|
99
|
+
raise _corrupt()
|
|
100
|
+
return cls(**{field: value[field] for field in _CREDENTIAL_FIELDS})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class CredentialLease:
|
|
105
|
+
"""The durable owner of one device authorization flow for one store generation."""
|
|
106
|
+
|
|
107
|
+
nonce: str
|
|
108
|
+
base_generation: int
|
|
109
|
+
operation: str
|
|
110
|
+
requested_device_name: str
|
|
111
|
+
created_at: str
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
if (
|
|
115
|
+
not isinstance(self.nonce, str)
|
|
116
|
+
or not self.nonce
|
|
117
|
+
or type(self.base_generation) is not int
|
|
118
|
+
or self.base_generation < 0
|
|
119
|
+
or self.operation not in _OPERATIONS
|
|
120
|
+
or not isinstance(self.requested_device_name, str)
|
|
121
|
+
or not self.requested_device_name
|
|
122
|
+
or not isinstance(self.created_at, str)
|
|
123
|
+
or not self.created_at
|
|
124
|
+
):
|
|
125
|
+
raise CredentialStoreError(
|
|
126
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected login lease is invalid"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
return (
|
|
131
|
+
"CredentialLease("
|
|
132
|
+
f"nonce={self.nonce!r}, base_generation={self.base_generation!r}, "
|
|
133
|
+
f"operation={self.operation!r}, requested_device_name={self.requested_device_name!r}, "
|
|
134
|
+
f"created_at={self.created_at!r})"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _payload(self) -> dict[str, str | int]:
|
|
138
|
+
return {
|
|
139
|
+
"nonce": self.nonce,
|
|
140
|
+
"base_generation": self.base_generation,
|
|
141
|
+
"operation": self.operation,
|
|
142
|
+
"requested_device_name": self.requested_device_name,
|
|
143
|
+
"created_at": self.created_at,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def _from_payload(cls, value: object) -> Self:
|
|
148
|
+
if not isinstance(value, dict) or set(value) != _LEASE_FIELDS:
|
|
149
|
+
raise _corrupt()
|
|
150
|
+
try:
|
|
151
|
+
return cls(
|
|
152
|
+
nonce=value["nonce"],
|
|
153
|
+
base_generation=value["base_generation"],
|
|
154
|
+
operation=value["operation"],
|
|
155
|
+
requested_device_name=value["requested_device_name"],
|
|
156
|
+
created_at=value["created_at"],
|
|
157
|
+
)
|
|
158
|
+
except (CredentialStoreError, TypeError):
|
|
159
|
+
raise _corrupt() from None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class CredentialEnvelope:
|
|
164
|
+
"""One exact state in the closed credential transaction protocol."""
|
|
165
|
+
|
|
166
|
+
generation: int
|
|
167
|
+
state: str
|
|
168
|
+
active: ProtectedCredential | None = None
|
|
169
|
+
prior: ProtectedCredential | None = None
|
|
170
|
+
login_pending: CredentialLease | None = None
|
|
171
|
+
|
|
172
|
+
def __post_init__(self) -> None:
|
|
173
|
+
if type(self.generation) is not int or self.generation < 0 or self.state not in _STATES:
|
|
174
|
+
raise _corrupt()
|
|
175
|
+
if self.state == "active":
|
|
176
|
+
valid = self.active is not None and self.prior is None and self.login_pending is None
|
|
177
|
+
elif self.state == "login_pending":
|
|
178
|
+
lease = self.login_pending
|
|
179
|
+
valid = (
|
|
180
|
+
lease is not None
|
|
181
|
+
and self.prior is None
|
|
182
|
+
and self.generation == lease.base_generation + 1
|
|
183
|
+
and (
|
|
184
|
+
(lease.operation == "login" and self.active is None)
|
|
185
|
+
or (lease.operation == "rotate" and self.active is not None)
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
elif self.state in {"revoke_pending", "cleanup_pending"}:
|
|
189
|
+
valid = (
|
|
190
|
+
self.active is not None and self.prior is not None and self.login_pending is None
|
|
191
|
+
)
|
|
192
|
+
else: # logout_pending
|
|
193
|
+
valid = self.active is not None and self.prior is None and self.login_pending is None
|
|
194
|
+
if not valid:
|
|
195
|
+
raise _corrupt()
|
|
196
|
+
|
|
197
|
+
def __repr__(self) -> str:
|
|
198
|
+
return (
|
|
199
|
+
"CredentialEnvelope("
|
|
200
|
+
f"generation={self.generation!r}, state={self.state!r}, active={self.active!r}, "
|
|
201
|
+
f"prior={self.prior!r}, login_pending={self.login_pending!r})"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def active_envelope(cls, *, generation: int, active: ProtectedCredential) -> Self:
|
|
206
|
+
return cls(generation=generation, state="active", active=active)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def login_pending_envelope(
|
|
210
|
+
cls,
|
|
211
|
+
*,
|
|
212
|
+
generation: int,
|
|
213
|
+
active: ProtectedCredential | None,
|
|
214
|
+
lease: CredentialLease,
|
|
215
|
+
) -> Self:
|
|
216
|
+
return cls(
|
|
217
|
+
generation=generation,
|
|
218
|
+
state="login_pending",
|
|
219
|
+
active=active,
|
|
220
|
+
login_pending=lease,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def pending_remote_revoke(
|
|
225
|
+
cls,
|
|
226
|
+
*,
|
|
227
|
+
generation: int,
|
|
228
|
+
active: ProtectedCredential,
|
|
229
|
+
prior: ProtectedCredential,
|
|
230
|
+
) -> Self:
|
|
231
|
+
return cls(
|
|
232
|
+
generation=generation,
|
|
233
|
+
state="revoke_pending",
|
|
234
|
+
active=active,
|
|
235
|
+
prior=prior,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def pending_cleanup(
|
|
240
|
+
cls,
|
|
241
|
+
*,
|
|
242
|
+
generation: int,
|
|
243
|
+
active: ProtectedCredential,
|
|
244
|
+
prior: ProtectedCredential,
|
|
245
|
+
) -> Self:
|
|
246
|
+
return cls(
|
|
247
|
+
generation=generation,
|
|
248
|
+
state="cleanup_pending",
|
|
249
|
+
active=active,
|
|
250
|
+
prior=prior,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def pending_logout(cls, *, generation: int, active: ProtectedCredential) -> Self:
|
|
255
|
+
return cls(generation=generation, state="logout_pending", active=active)
|
|
256
|
+
|
|
257
|
+
def to_bytes(self) -> bytes:
|
|
258
|
+
payload: dict[str, object] = {
|
|
259
|
+
"schema_version": CREDENTIAL_ENVELOPE_SCHEMA,
|
|
260
|
+
"generation": self.generation,
|
|
261
|
+
"state": self.state,
|
|
262
|
+
}
|
|
263
|
+
if self.active is not None:
|
|
264
|
+
payload["active"] = self.active._payload()
|
|
265
|
+
if self.prior is not None:
|
|
266
|
+
payload["prior"] = self.prior._payload()
|
|
267
|
+
if self.login_pending is not None:
|
|
268
|
+
payload["login_pending"] = self.login_pending._payload()
|
|
269
|
+
return canonical_json_bytes(payload)
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def from_bytes(cls, value: bytes | bytearray | memoryview) -> Self:
|
|
273
|
+
raw = bytes(value)
|
|
274
|
+
if len(raw) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
275
|
+
raise CredentialStoreError(
|
|
276
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential envelope exceeds its limit"
|
|
277
|
+
)
|
|
278
|
+
try:
|
|
279
|
+
parsed = parse_canonical_json(raw)
|
|
280
|
+
except (CanonicalJSONError, UnicodeError, ValueError):
|
|
281
|
+
raise _corrupt() from None
|
|
282
|
+
if not isinstance(parsed, dict):
|
|
283
|
+
raise _corrupt()
|
|
284
|
+
state = parsed.get("state")
|
|
285
|
+
required = {"schema_version", "generation", "state"}
|
|
286
|
+
if state == "active" or state == "logout_pending":
|
|
287
|
+
required.add("active")
|
|
288
|
+
elif state == "login_pending":
|
|
289
|
+
required.add("login_pending")
|
|
290
|
+
if "active" in parsed:
|
|
291
|
+
required.add("active")
|
|
292
|
+
elif state in {"revoke_pending", "cleanup_pending"}:
|
|
293
|
+
required.update({"active", "prior"})
|
|
294
|
+
if set(parsed) != required or parsed.get("schema_version") != CREDENTIAL_ENVELOPE_SCHEMA:
|
|
295
|
+
raise _corrupt()
|
|
296
|
+
try:
|
|
297
|
+
return cls(
|
|
298
|
+
generation=parsed["generation"],
|
|
299
|
+
state=state,
|
|
300
|
+
active=ProtectedCredential._from_payload(parsed["active"])
|
|
301
|
+
if "active" in parsed
|
|
302
|
+
else None,
|
|
303
|
+
prior=ProtectedCredential._from_payload(parsed["prior"])
|
|
304
|
+
if "prior" in parsed
|
|
305
|
+
else None,
|
|
306
|
+
login_pending=CredentialLease._from_payload(parsed["login_pending"])
|
|
307
|
+
if "login_pending" in parsed
|
|
308
|
+
else None,
|
|
309
|
+
)
|
|
310
|
+
except (CredentialStoreError, KeyError, TypeError):
|
|
311
|
+
raise _corrupt() from None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class CredentialBackend(Protocol):
|
|
315
|
+
"""A backend that stores one opaque protected-envelope payload."""
|
|
316
|
+
|
|
317
|
+
name: str
|
|
318
|
+
|
|
319
|
+
def read(self) -> bytes | None: ...
|
|
320
|
+
|
|
321
|
+
def write(self, payload: bytes) -> None: ...
|
|
322
|
+
|
|
323
|
+
def delete(self) -> None: ...
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class SecureFileBackend:
|
|
327
|
+
"""Explicit, atomic 0600 fallback backend; never selected implicitly."""
|
|
328
|
+
|
|
329
|
+
name = "secure-file"
|
|
330
|
+
|
|
331
|
+
def __init__(
|
|
332
|
+
self,
|
|
333
|
+
config_dir: Path,
|
|
334
|
+
*,
|
|
335
|
+
explicit: bool,
|
|
336
|
+
filename: str = SECURE_FILE_NAME,
|
|
337
|
+
) -> None:
|
|
338
|
+
if explicit is not True:
|
|
339
|
+
raise CredentialStoreError(
|
|
340
|
+
"CREDENTIAL_STORE_EXPLICIT_REQUIRED",
|
|
341
|
+
"secure-file storage requires explicit selection",
|
|
342
|
+
)
|
|
343
|
+
self.config_dir = Path(config_dir)
|
|
344
|
+
if not filename or filename in {".", ".."} or Path(filename).name != filename:
|
|
345
|
+
raise CredentialStoreError(
|
|
346
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential filename is invalid"
|
|
347
|
+
)
|
|
348
|
+
self._filename = filename
|
|
349
|
+
self.path = self.config_dir / filename
|
|
350
|
+
_ensure_private_directory(self.config_dir)
|
|
351
|
+
|
|
352
|
+
def read(self) -> bytes | None:
|
|
353
|
+
_validate_private_directory(self.config_dir)
|
|
354
|
+
try:
|
|
355
|
+
descriptor = os.open(
|
|
356
|
+
self.path,
|
|
357
|
+
os.O_RDONLY
|
|
358
|
+
| getattr(os, "O_BINARY", 0)
|
|
359
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
360
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
361
|
+
)
|
|
362
|
+
except FileNotFoundError:
|
|
363
|
+
if os.path.lexists(self.path):
|
|
364
|
+
raise _permission_refusal() from None
|
|
365
|
+
return None
|
|
366
|
+
except OSError:
|
|
367
|
+
raise _permission_refusal() from None
|
|
368
|
+
try:
|
|
369
|
+
info = os.fstat(descriptor)
|
|
370
|
+
_validate_private_file(info, path=self.path)
|
|
371
|
+
if info.st_size > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
372
|
+
raise CredentialStoreError(
|
|
373
|
+
"CREDENTIAL_STORE_CORRUPT",
|
|
374
|
+
"the protected credential envelope exceeds its limit",
|
|
375
|
+
)
|
|
376
|
+
chunks: list[bytes] = []
|
|
377
|
+
remaining = MAX_CREDENTIAL_ENVELOPE_BYTES + 1
|
|
378
|
+
while remaining:
|
|
379
|
+
chunk = os.read(descriptor, min(remaining, 16 * 1024))
|
|
380
|
+
if not chunk:
|
|
381
|
+
break
|
|
382
|
+
chunks.append(chunk)
|
|
383
|
+
remaining -= len(chunk)
|
|
384
|
+
raw = b"".join(chunks)
|
|
385
|
+
if len(raw) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
386
|
+
raise CredentialStoreError(
|
|
387
|
+
"CREDENTIAL_STORE_CORRUPT",
|
|
388
|
+
"the protected credential envelope exceeds its limit",
|
|
389
|
+
)
|
|
390
|
+
return raw
|
|
391
|
+
except CredentialStoreError:
|
|
392
|
+
raise
|
|
393
|
+
except OSError:
|
|
394
|
+
raise CredentialStoreError(
|
|
395
|
+
"CREDENTIAL_STORE_UNAVAILABLE", "secure-file storage could not be read"
|
|
396
|
+
) from None
|
|
397
|
+
finally:
|
|
398
|
+
os.close(descriptor)
|
|
399
|
+
|
|
400
|
+
def write(self, payload: bytes) -> None:
|
|
401
|
+
_validate_private_directory(self.config_dir)
|
|
402
|
+
if len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
403
|
+
raise CredentialStoreError(
|
|
404
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential envelope exceeds its limit"
|
|
405
|
+
)
|
|
406
|
+
if os.path.lexists(self.path):
|
|
407
|
+
# Validate the current target before replacing it. Atomic replacement is not license
|
|
408
|
+
# to overwrite a link, hardlink, foreign owner, or permissive file.
|
|
409
|
+
self.read()
|
|
410
|
+
stage_name = f".{self._filename}.{secrets.token_hex(16)}.stage"
|
|
411
|
+
stage = self.config_dir / stage_name
|
|
412
|
+
descriptor: int | None = None
|
|
413
|
+
directory_fd: int | None = None
|
|
414
|
+
try:
|
|
415
|
+
descriptor = os.open(
|
|
416
|
+
stage,
|
|
417
|
+
os.O_WRONLY
|
|
418
|
+
| os.O_CREAT
|
|
419
|
+
| os.O_EXCL
|
|
420
|
+
| getattr(os, "O_BINARY", 0)
|
|
421
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
422
|
+
0o600,
|
|
423
|
+
)
|
|
424
|
+
if os.name == "posix":
|
|
425
|
+
os.fchmod(descriptor, 0o600)
|
|
426
|
+
offset = 0
|
|
427
|
+
while offset < len(payload):
|
|
428
|
+
written = os.write(descriptor, payload[offset:])
|
|
429
|
+
if written <= 0:
|
|
430
|
+
raise OSError("short credential write")
|
|
431
|
+
offset += written
|
|
432
|
+
os.fsync(descriptor)
|
|
433
|
+
_validate_private_file(os.fstat(descriptor), path=stage)
|
|
434
|
+
os.close(descriptor)
|
|
435
|
+
descriptor = None
|
|
436
|
+
os.replace(stage, self.path)
|
|
437
|
+
if os.name == "posix":
|
|
438
|
+
directory_fd = os.open(
|
|
439
|
+
self.config_dir,
|
|
440
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
441
|
+
)
|
|
442
|
+
os.fsync(directory_fd)
|
|
443
|
+
except CredentialStoreError:
|
|
444
|
+
raise
|
|
445
|
+
except OSError:
|
|
446
|
+
raise CredentialStoreError(
|
|
447
|
+
"CREDENTIAL_STORE_WRITE_FAILED", "secure-file storage could not be updated"
|
|
448
|
+
) from None
|
|
449
|
+
finally:
|
|
450
|
+
if descriptor is not None:
|
|
451
|
+
os.close(descriptor)
|
|
452
|
+
if directory_fd is not None:
|
|
453
|
+
os.close(directory_fd)
|
|
454
|
+
with contextlib.suppress(OSError):
|
|
455
|
+
stage.unlink()
|
|
456
|
+
|
|
457
|
+
def delete(self) -> None:
|
|
458
|
+
if self.read() is None:
|
|
459
|
+
return
|
|
460
|
+
try:
|
|
461
|
+
self.path.unlink()
|
|
462
|
+
if os.name == "posix":
|
|
463
|
+
directory_fd = os.open(
|
|
464
|
+
self.config_dir,
|
|
465
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
466
|
+
)
|
|
467
|
+
try:
|
|
468
|
+
os.fsync(directory_fd)
|
|
469
|
+
finally:
|
|
470
|
+
os.close(directory_fd)
|
|
471
|
+
except OSError:
|
|
472
|
+
raise CredentialStoreError(
|
|
473
|
+
"CREDENTIAL_STORE_WRITE_FAILED", "secure-file storage could not be removed"
|
|
474
|
+
) from None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@contextlib.contextmanager
|
|
478
|
+
def credential_lock(config_dir: Path, *, timeout_seconds: float = 5.0) -> Iterator[None]:
|
|
479
|
+
"""Hold the per-config-root cross-process credential lock."""
|
|
480
|
+
|
|
481
|
+
root = Path(config_dir)
|
|
482
|
+
_ensure_private_directory(root)
|
|
483
|
+
_validate_private_directory(root)
|
|
484
|
+
lock_path = root / LOCK_FILE_NAME
|
|
485
|
+
descriptor: int | None = None
|
|
486
|
+
try:
|
|
487
|
+
open_flags = os.O_RDWR | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
488
|
+
try:
|
|
489
|
+
descriptor = os.open(lock_path, open_flags | os.O_CREAT | os.O_EXCL, 0o600)
|
|
490
|
+
created = True
|
|
491
|
+
except FileExistsError:
|
|
492
|
+
descriptor = os.open(lock_path, open_flags)
|
|
493
|
+
created = False
|
|
494
|
+
if os.name == "posix" and created:
|
|
495
|
+
os.fchmod(descriptor, 0o600)
|
|
496
|
+
_validate_private_file(os.fstat(descriptor), path=lock_path)
|
|
497
|
+
except (OSError, CredentialStoreError):
|
|
498
|
+
if descriptor is not None:
|
|
499
|
+
os.close(descriptor)
|
|
500
|
+
raise CredentialStoreError(
|
|
501
|
+
"CREDENTIAL_LOCK_UNAVAILABLE", "the credential lock could not be opened"
|
|
502
|
+
) from None
|
|
503
|
+
acquired = False
|
|
504
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
505
|
+
try:
|
|
506
|
+
if os.name == "nt":
|
|
507
|
+
import msvcrt
|
|
508
|
+
|
|
509
|
+
if os.fstat(descriptor).st_size == 0:
|
|
510
|
+
os.write(descriptor, b"\0")
|
|
511
|
+
os.fsync(descriptor)
|
|
512
|
+
while not acquired:
|
|
513
|
+
try:
|
|
514
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
515
|
+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
|
516
|
+
acquired = True
|
|
517
|
+
except OSError:
|
|
518
|
+
if time.monotonic() >= deadline:
|
|
519
|
+
break
|
|
520
|
+
time.sleep(0.01)
|
|
521
|
+
else:
|
|
522
|
+
import fcntl
|
|
523
|
+
|
|
524
|
+
while not acquired:
|
|
525
|
+
try:
|
|
526
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
527
|
+
acquired = True
|
|
528
|
+
except BlockingIOError:
|
|
529
|
+
if time.monotonic() >= deadline:
|
|
530
|
+
break
|
|
531
|
+
time.sleep(0.01)
|
|
532
|
+
if not acquired:
|
|
533
|
+
raise CredentialStoreError(
|
|
534
|
+
"CREDENTIAL_LOCKED", "another credential operation holds the local lock"
|
|
535
|
+
)
|
|
536
|
+
yield
|
|
537
|
+
finally:
|
|
538
|
+
if acquired:
|
|
539
|
+
if os.name == "nt":
|
|
540
|
+
import msvcrt
|
|
541
|
+
|
|
542
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
543
|
+
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
|
544
|
+
else:
|
|
545
|
+
import fcntl
|
|
546
|
+
|
|
547
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
548
|
+
os.close(descriptor)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class CredentialStore:
|
|
552
|
+
"""Locking generation/lease facade over one selected backend."""
|
|
553
|
+
|
|
554
|
+
def __init__(self, config_dir: Path, backend: CredentialBackend) -> None:
|
|
555
|
+
self.config_dir = Path(config_dir)
|
|
556
|
+
self.backend = backend
|
|
557
|
+
|
|
558
|
+
def _load_unlocked(self) -> CredentialEnvelope | None:
|
|
559
|
+
payload = self.backend.read()
|
|
560
|
+
return None if payload is None else CredentialEnvelope.from_bytes(payload)
|
|
561
|
+
|
|
562
|
+
def load(self) -> CredentialEnvelope | None:
|
|
563
|
+
with credential_lock(self.config_dir):
|
|
564
|
+
return self._load_unlocked()
|
|
565
|
+
|
|
566
|
+
def load_under_held_lock(self) -> CredentialEnvelope | None:
|
|
567
|
+
"""Load while a caller coordinating multiple local files already holds this root's lock."""
|
|
568
|
+
|
|
569
|
+
return self._load_unlocked()
|
|
570
|
+
|
|
571
|
+
def write_under_held_lock(self, envelope: CredentialEnvelope) -> None:
|
|
572
|
+
"""Write under an outer lock, resuming only a byte-identical interrupted install."""
|
|
573
|
+
|
|
574
|
+
incoming = envelope.to_bytes()
|
|
575
|
+
existing = self.backend.read()
|
|
576
|
+
if existing is not None and secrets.compare_digest(existing, incoming):
|
|
577
|
+
return
|
|
578
|
+
current = None if existing is None else CredentialEnvelope.from_bytes(existing)
|
|
579
|
+
_validate_saved_transition(
|
|
580
|
+
current,
|
|
581
|
+
envelope,
|
|
582
|
+
expected_generation=None if current is None else current.generation,
|
|
583
|
+
)
|
|
584
|
+
self.backend.write(incoming)
|
|
585
|
+
|
|
586
|
+
def replace_under_held_lock(
|
|
587
|
+
self,
|
|
588
|
+
envelope: CredentialEnvelope,
|
|
589
|
+
*,
|
|
590
|
+
expected_generation: int,
|
|
591
|
+
expected_states: frozenset[str],
|
|
592
|
+
) -> CredentialEnvelope:
|
|
593
|
+
"""Advance one known recovery transition while an outer credential lock is held.
|
|
594
|
+
|
|
595
|
+
Generic ``save`` intentionally permits only active-to-active replacement. Lifecycle
|
|
596
|
+
orchestration needs the same generation proof for its closed pending states without
|
|
597
|
+
creating a second, unchecked backend write path.
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
current = self._load_unlocked()
|
|
601
|
+
if (
|
|
602
|
+
current is None
|
|
603
|
+
or current.generation != expected_generation
|
|
604
|
+
or current.state not in expected_states
|
|
605
|
+
or envelope.generation != expected_generation + 1
|
|
606
|
+
):
|
|
607
|
+
raise CredentialStoreError(
|
|
608
|
+
"CREDENTIAL_GENERATION_CONFLICT",
|
|
609
|
+
"the protected credential transaction changed before it could advance",
|
|
610
|
+
)
|
|
611
|
+
self.backend.write(envelope.to_bytes())
|
|
612
|
+
return envelope
|
|
613
|
+
|
|
614
|
+
def delete_under_held_lock(
|
|
615
|
+
self,
|
|
616
|
+
*,
|
|
617
|
+
expected_generation: int,
|
|
618
|
+
expected_states: frozenset[str],
|
|
619
|
+
) -> None:
|
|
620
|
+
"""Remove protected material only from a caller-proved terminal cleanup state."""
|
|
621
|
+
|
|
622
|
+
current = self._load_unlocked()
|
|
623
|
+
if (
|
|
624
|
+
current is None
|
|
625
|
+
or current.generation != expected_generation
|
|
626
|
+
or current.state not in expected_states
|
|
627
|
+
):
|
|
628
|
+
raise CredentialStoreError(
|
|
629
|
+
"CREDENTIAL_GENERATION_CONFLICT",
|
|
630
|
+
"the protected credential transaction changed before cleanup",
|
|
631
|
+
)
|
|
632
|
+
self.backend.delete()
|
|
633
|
+
|
|
634
|
+
def save(
|
|
635
|
+
self, envelope: CredentialEnvelope, *, expected_generation: int | None
|
|
636
|
+
) -> CredentialEnvelope:
|
|
637
|
+
with credential_lock(self.config_dir):
|
|
638
|
+
current = self._load_unlocked()
|
|
639
|
+
_validate_saved_transition(current, envelope, expected_generation=expected_generation)
|
|
640
|
+
self.backend.write(envelope.to_bytes())
|
|
641
|
+
return envelope
|
|
642
|
+
|
|
643
|
+
def acquire_login_lease(
|
|
644
|
+
self,
|
|
645
|
+
*,
|
|
646
|
+
operation: str,
|
|
647
|
+
requested_device_name: str,
|
|
648
|
+
created_at: str,
|
|
649
|
+
nonce: str | None = None,
|
|
650
|
+
) -> CredentialEnvelope:
|
|
651
|
+
with credential_lock(self.config_dir):
|
|
652
|
+
current = self._load_unlocked()
|
|
653
|
+
if current is not None and current.state == "login_pending":
|
|
654
|
+
raise CredentialStoreError(
|
|
655
|
+
"CREDENTIAL_LOGIN_PENDING",
|
|
656
|
+
"another device flow is pending and its mint outcome is unproved",
|
|
657
|
+
)
|
|
658
|
+
if current is not None and current.state != "active":
|
|
659
|
+
raise CredentialStoreError(
|
|
660
|
+
"CREDENTIAL_RECOVERY_REQUIRED",
|
|
661
|
+
"the protected credential requires recovery before another mutation",
|
|
662
|
+
)
|
|
663
|
+
base_generation = 0 if current is None else current.generation
|
|
664
|
+
active = None if current is None else current.active
|
|
665
|
+
lease = CredentialLease(
|
|
666
|
+
nonce=nonce or secrets.token_urlsafe(32),
|
|
667
|
+
base_generation=base_generation,
|
|
668
|
+
operation=operation,
|
|
669
|
+
requested_device_name=requested_device_name,
|
|
670
|
+
created_at=created_at,
|
|
671
|
+
)
|
|
672
|
+
pending = CredentialEnvelope.login_pending_envelope(
|
|
673
|
+
generation=base_generation + 1,
|
|
674
|
+
active=active,
|
|
675
|
+
lease=lease,
|
|
676
|
+
)
|
|
677
|
+
self.backend.write(pending.to_bytes())
|
|
678
|
+
return pending
|
|
679
|
+
|
|
680
|
+
def complete_login_lease(
|
|
681
|
+
self, *, nonce: str, minted: ProtectedCredential
|
|
682
|
+
) -> CredentialEnvelope:
|
|
683
|
+
with credential_lock(self.config_dir):
|
|
684
|
+
current = self._load_unlocked()
|
|
685
|
+
lease = None if current is None else current.login_pending
|
|
686
|
+
if (
|
|
687
|
+
current is None
|
|
688
|
+
or current.state != "login_pending"
|
|
689
|
+
or lease is None
|
|
690
|
+
or lease.nonce != nonce
|
|
691
|
+
or current.generation != lease.base_generation + 1
|
|
692
|
+
):
|
|
693
|
+
raise CredentialStoreError(
|
|
694
|
+
"CREDENTIAL_LEASE_CONFLICT",
|
|
695
|
+
"the device flow no longer owns the protected credential generation",
|
|
696
|
+
)
|
|
697
|
+
if current.active is None:
|
|
698
|
+
installed = CredentialEnvelope.active_envelope(
|
|
699
|
+
generation=current.generation + 1,
|
|
700
|
+
active=minted,
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
installed = CredentialEnvelope.pending_remote_revoke(
|
|
704
|
+
generation=current.generation + 1,
|
|
705
|
+
active=minted,
|
|
706
|
+
prior=current.active,
|
|
707
|
+
)
|
|
708
|
+
self.backend.write(installed.to_bytes())
|
|
709
|
+
# Durability proof belongs inside the same lock as the install.
|
|
710
|
+
# Once this method returns, another process may legitimately
|
|
711
|
+
# recover this generation or acquire the next lease; callers must
|
|
712
|
+
# never reinterpret that later drift as a failed mint persistence.
|
|
713
|
+
if self._load_unlocked() != installed:
|
|
714
|
+
raise CredentialStoreError(
|
|
715
|
+
"CREDENTIAL_STORE_READBACK_FAILED",
|
|
716
|
+
"the replacement could not be verified",
|
|
717
|
+
)
|
|
718
|
+
return installed
|
|
719
|
+
|
|
720
|
+
def recover_login_lease_local_only(self) -> dict[str, str]:
|
|
721
|
+
with credential_lock(self.config_dir):
|
|
722
|
+
current = self._load_unlocked()
|
|
723
|
+
if current is None or current.state != "login_pending":
|
|
724
|
+
raise CredentialStoreError(
|
|
725
|
+
"CREDENTIAL_RECOVERY_NOT_APPLICABLE",
|
|
726
|
+
"there is no pending device flow to recover",
|
|
727
|
+
)
|
|
728
|
+
if current.active is None:
|
|
729
|
+
self.backend.delete()
|
|
730
|
+
else:
|
|
731
|
+
self.backend.write(
|
|
732
|
+
CredentialEnvelope.active_envelope(
|
|
733
|
+
generation=current.generation + 1,
|
|
734
|
+
active=current.active,
|
|
735
|
+
).to_bytes()
|
|
736
|
+
)
|
|
737
|
+
return {"mint_outcome": "unproved", "remote_cleanup": "unproved"}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _corrupt() -> CredentialStoreError:
|
|
741
|
+
return CredentialStoreError(
|
|
742
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential envelope is invalid"
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _permission_refusal() -> CredentialStoreError:
|
|
747
|
+
return CredentialStoreError(
|
|
748
|
+
"CREDENTIAL_STORE_PERMISSIONS",
|
|
749
|
+
"secure-file storage is not an owner-controlled regular single-link file",
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _validate_saved_transition(
|
|
754
|
+
current: CredentialEnvelope | None,
|
|
755
|
+
incoming: CredentialEnvelope,
|
|
756
|
+
*,
|
|
757
|
+
expected_generation: int | None,
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Prove a generic save is exactly one active-to-active generation transition."""
|
|
760
|
+
|
|
761
|
+
actual_generation = None if current is None else current.generation
|
|
762
|
+
if actual_generation != expected_generation:
|
|
763
|
+
raise CredentialStoreError(
|
|
764
|
+
"CREDENTIAL_GENERATION_CONFLICT",
|
|
765
|
+
"the protected credential generation changed",
|
|
766
|
+
)
|
|
767
|
+
if current is not None and current.state != "active":
|
|
768
|
+
raise CredentialStoreError(
|
|
769
|
+
"CREDENTIAL_RECOVERY_REQUIRED",
|
|
770
|
+
"the protected credential requires recovery before another mutation",
|
|
771
|
+
)
|
|
772
|
+
next_generation = 1 if current is None else current.generation + 1
|
|
773
|
+
if incoming.generation != next_generation:
|
|
774
|
+
raise CredentialStoreError(
|
|
775
|
+
"CREDENTIAL_GENERATION_CONFLICT",
|
|
776
|
+
"the protected credential generation must advance exactly once",
|
|
777
|
+
)
|
|
778
|
+
if incoming.state != "active":
|
|
779
|
+
raise CredentialStoreError(
|
|
780
|
+
"CREDENTIAL_RECOVERY_REQUIRED",
|
|
781
|
+
"generic credential saves may only install an active generation",
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _ensure_private_directory(path: Path) -> None:
|
|
786
|
+
try:
|
|
787
|
+
try:
|
|
788
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
789
|
+
created = True
|
|
790
|
+
except FileExistsError:
|
|
791
|
+
created = False
|
|
792
|
+
if created and os.name == "posix":
|
|
793
|
+
os.chmod(path, 0o700, follow_symlinks=False)
|
|
794
|
+
_validate_private_directory(path)
|
|
795
|
+
except OSError:
|
|
796
|
+
raise CredentialStoreError(
|
|
797
|
+
"CREDENTIAL_STORE_PERMISSIONS", "the credential directory is not owner controlled"
|
|
798
|
+
) from None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _validate_private_directory(path: Path) -> None:
|
|
802
|
+
try:
|
|
803
|
+
info = path.stat(follow_symlinks=False)
|
|
804
|
+
except OSError:
|
|
805
|
+
raise CredentialStoreError(
|
|
806
|
+
"CREDENTIAL_STORE_PERMISSIONS", "the credential directory is not owner controlled"
|
|
807
|
+
) from None
|
|
808
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
809
|
+
raise CredentialStoreError(
|
|
810
|
+
"CREDENTIAL_STORE_PERMISSIONS", "the credential directory is not owner controlled"
|
|
811
|
+
)
|
|
812
|
+
if os.name == "posix" and (info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700):
|
|
813
|
+
raise CredentialStoreError(
|
|
814
|
+
"CREDENTIAL_STORE_PERMISSIONS", "the credential directory is not owner controlled"
|
|
815
|
+
)
|
|
816
|
+
if os.name == "nt" and not _windows_acl_is_private(path):
|
|
817
|
+
raise CredentialStoreError(
|
|
818
|
+
"CREDENTIAL_STORE_PERMISSIONS", "the credential directory is not owner controlled"
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _validate_private_file(info: os.stat_result, *, path: Path | None = None) -> None:
|
|
823
|
+
valid = stat.S_ISREG(info.st_mode) and info.st_nlink == 1
|
|
824
|
+
if os.name == "posix":
|
|
825
|
+
valid = valid and info.st_uid == os.geteuid() and stat.S_IMODE(info.st_mode) == 0o600
|
|
826
|
+
elif os.name == "nt":
|
|
827
|
+
valid = path is not None and _windows_acl_is_private(path)
|
|
828
|
+
if not valid:
|
|
829
|
+
raise _permission_refusal()
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _windows_acl_is_private(path: Path) -> bool:
|
|
833
|
+
"""Prove only the current user, SYSTEM, or Administrators can mutate a Windows path."""
|
|
834
|
+
|
|
835
|
+
if os.name != "nt":
|
|
836
|
+
return True
|
|
837
|
+
try:
|
|
838
|
+
import ctypes
|
|
839
|
+
from ctypes import wintypes
|
|
840
|
+
|
|
841
|
+
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
842
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
843
|
+
_configure_windows_security_apis(advapi32, kernel32)
|
|
844
|
+
get_attributes = kernel32.GetFileAttributesW
|
|
845
|
+
get_attributes.argtypes = [wintypes.LPCWSTR]
|
|
846
|
+
get_attributes.restype = wintypes.DWORD
|
|
847
|
+
attributes = get_attributes(str(path))
|
|
848
|
+
if attributes == 0xFFFFFFFF or attributes & 0x400: # invalid or reparse point
|
|
849
|
+
return False
|
|
850
|
+
|
|
851
|
+
owner = ctypes.c_void_p()
|
|
852
|
+
dacl = ctypes.c_void_p()
|
|
853
|
+
descriptor = ctypes.c_void_p()
|
|
854
|
+
get_security = advapi32.GetNamedSecurityInfoW
|
|
855
|
+
result = get_security(
|
|
856
|
+
str(path),
|
|
857
|
+
1, # SE_FILE_OBJECT
|
|
858
|
+
0x1 | 0x4, # OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
|
|
859
|
+
ctypes.byref(owner),
|
|
860
|
+
None,
|
|
861
|
+
ctypes.byref(dacl),
|
|
862
|
+
None,
|
|
863
|
+
ctypes.byref(descriptor),
|
|
864
|
+
)
|
|
865
|
+
if result != 0 or not owner.value or not dacl.value or not descriptor.value:
|
|
866
|
+
return False
|
|
867
|
+
try:
|
|
868
|
+
owned_current_sid = _windows_current_user_sid(advapi32, kernel32)
|
|
869
|
+
if owned_current_sid is None:
|
|
870
|
+
return False
|
|
871
|
+
current_sid, _current_sid_backing = owned_current_sid
|
|
872
|
+
if not advapi32.EqualSid(owner, current_sid):
|
|
873
|
+
return False
|
|
874
|
+
allowed = [current_sid]
|
|
875
|
+
allocated: list[ctypes.c_void_p] = []
|
|
876
|
+
for text in ("S-1-5-18", "S-1-5-32-544"): # SYSTEM, Administrators
|
|
877
|
+
sid = ctypes.c_void_p()
|
|
878
|
+
if not advapi32.ConvertStringSidToSidW(text, ctypes.byref(sid)) or not sid.value:
|
|
879
|
+
return False
|
|
880
|
+
allocated.append(sid)
|
|
881
|
+
allowed.append(sid.value)
|
|
882
|
+
try:
|
|
883
|
+
return _windows_acl_has_only_allowed_writers(advapi32, dacl, allowed)
|
|
884
|
+
finally:
|
|
885
|
+
for sid in allocated:
|
|
886
|
+
kernel32.LocalFree(sid)
|
|
887
|
+
finally:
|
|
888
|
+
kernel32.LocalFree(descriptor)
|
|
889
|
+
except (AttributeError, OSError, TypeError, ValueError):
|
|
890
|
+
return False
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _configure_windows_security_apis(advapi32: object, kernel32: object) -> None:
|
|
894
|
+
"""Bind every pointer-bearing ACL API before the first 64-bit Windows call."""
|
|
895
|
+
|
|
896
|
+
import ctypes
|
|
897
|
+
from ctypes import wintypes
|
|
898
|
+
|
|
899
|
+
void_pointer_pointer = ctypes.POINTER(ctypes.c_void_p)
|
|
900
|
+
advapi32.GetNamedSecurityInfoW.argtypes = [
|
|
901
|
+
wintypes.LPCWSTR,
|
|
902
|
+
wintypes.DWORD,
|
|
903
|
+
wintypes.DWORD,
|
|
904
|
+
void_pointer_pointer,
|
|
905
|
+
void_pointer_pointer,
|
|
906
|
+
void_pointer_pointer,
|
|
907
|
+
void_pointer_pointer,
|
|
908
|
+
void_pointer_pointer,
|
|
909
|
+
]
|
|
910
|
+
advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
911
|
+
advapi32.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
|
912
|
+
advapi32.EqualSid.restype = wintypes.BOOL
|
|
913
|
+
advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, void_pointer_pointer]
|
|
914
|
+
advapi32.ConvertStringSidToSidW.restype = wintypes.BOOL
|
|
915
|
+
advapi32.OpenProcessToken.argtypes = [
|
|
916
|
+
wintypes.HANDLE,
|
|
917
|
+
wintypes.DWORD,
|
|
918
|
+
ctypes.POINTER(wintypes.HANDLE),
|
|
919
|
+
]
|
|
920
|
+
advapi32.OpenProcessToken.restype = wintypes.BOOL
|
|
921
|
+
advapi32.GetTokenInformation.argtypes = [
|
|
922
|
+
wintypes.HANDLE,
|
|
923
|
+
ctypes.c_int,
|
|
924
|
+
ctypes.c_void_p,
|
|
925
|
+
wintypes.DWORD,
|
|
926
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
927
|
+
]
|
|
928
|
+
advapi32.GetTokenInformation.restype = wintypes.BOOL
|
|
929
|
+
advapi32.GetAce.argtypes = [ctypes.c_void_p, wintypes.DWORD, void_pointer_pointer]
|
|
930
|
+
advapi32.GetAce.restype = wintypes.BOOL
|
|
931
|
+
kernel32.GetCurrentProcess.argtypes = []
|
|
932
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
933
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
934
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
935
|
+
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
936
|
+
kernel32.LocalFree.restype = ctypes.c_void_p
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _windows_current_user_sid(advapi32: object, kernel32: object) -> tuple[int, object] | None:
|
|
940
|
+
import ctypes
|
|
941
|
+
from ctypes import wintypes
|
|
942
|
+
|
|
943
|
+
token = wintypes.HANDLE()
|
|
944
|
+
if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x8, ctypes.byref(token)):
|
|
945
|
+
return None
|
|
946
|
+
try:
|
|
947
|
+
required = wintypes.DWORD()
|
|
948
|
+
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required))
|
|
949
|
+
if required.value == 0:
|
|
950
|
+
return None
|
|
951
|
+
buffer = ctypes.create_string_buffer(required.value)
|
|
952
|
+
if not advapi32.GetTokenInformation(
|
|
953
|
+
token, 1, buffer, required.value, ctypes.byref(required)
|
|
954
|
+
):
|
|
955
|
+
return None
|
|
956
|
+
sid = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p)).contents.value
|
|
957
|
+
if sid is None:
|
|
958
|
+
return None
|
|
959
|
+
return sid, buffer
|
|
960
|
+
finally:
|
|
961
|
+
kernel32.CloseHandle(token)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _windows_acl_has_only_allowed_writers(
|
|
965
|
+
advapi32: object,
|
|
966
|
+
dacl: object,
|
|
967
|
+
allowed_sids: list[int],
|
|
968
|
+
) -> bool:
|
|
969
|
+
import ctypes
|
|
970
|
+
from ctypes import wintypes
|
|
971
|
+
|
|
972
|
+
class ACL(ctypes.Structure):
|
|
973
|
+
_fields_ = [
|
|
974
|
+
("revision", ctypes.c_ubyte),
|
|
975
|
+
("reserved", ctypes.c_ubyte),
|
|
976
|
+
("size", ctypes.c_ushort),
|
|
977
|
+
("ace_count", ctypes.c_ushort),
|
|
978
|
+
("reserved2", ctypes.c_ushort),
|
|
979
|
+
]
|
|
980
|
+
|
|
981
|
+
class ACE_HEADER(ctypes.Structure):
|
|
982
|
+
_fields_ = [
|
|
983
|
+
("ace_type", ctypes.c_ubyte),
|
|
984
|
+
("ace_flags", ctypes.c_ubyte),
|
|
985
|
+
("ace_size", ctypes.c_ushort),
|
|
986
|
+
]
|
|
987
|
+
|
|
988
|
+
class ACCESS_ALLOWED_ACE(ctypes.Structure):
|
|
989
|
+
_fields_ = [
|
|
990
|
+
("header", ACE_HEADER),
|
|
991
|
+
("mask", wintypes.DWORD),
|
|
992
|
+
("sid_start", wintypes.DWORD),
|
|
993
|
+
]
|
|
994
|
+
|
|
995
|
+
write_mask = (
|
|
996
|
+
0x2
|
|
997
|
+
| 0x4
|
|
998
|
+
| 0x10
|
|
999
|
+
| 0x40 # FILE_DELETE_CHILD grants directory-entry deletion.
|
|
1000
|
+
| 0x100
|
|
1001
|
+
| 0x10000
|
|
1002
|
+
| 0x40000
|
|
1003
|
+
| 0x80000
|
|
1004
|
+
| 0x10000000
|
|
1005
|
+
| 0x40000000
|
|
1006
|
+
)
|
|
1007
|
+
non_granting_ace_types = {
|
|
1008
|
+
1, # ACCESS_DENIED_ACE_TYPE
|
|
1009
|
+
2, # SYSTEM_AUDIT_ACE_TYPE
|
|
1010
|
+
3, # SYSTEM_ALARM_ACE_TYPE
|
|
1011
|
+
6, # ACCESS_DENIED_OBJECT_ACE_TYPE
|
|
1012
|
+
7, # SYSTEM_AUDIT_OBJECT_ACE_TYPE
|
|
1013
|
+
8, # SYSTEM_ALARM_OBJECT_ACE_TYPE
|
|
1014
|
+
10, # ACCESS_DENIED_CALLBACK_ACE_TYPE
|
|
1015
|
+
12, # ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE
|
|
1016
|
+
13, # SYSTEM_AUDIT_CALLBACK_ACE_TYPE
|
|
1017
|
+
14, # SYSTEM_ALARM_CALLBACK_ACE_TYPE
|
|
1018
|
+
15, # SYSTEM_AUDIT_CALLBACK_OBJECT_ACE_TYPE
|
|
1019
|
+
16, # SYSTEM_ALARM_CALLBACK_OBJECT_ACE_TYPE
|
|
1020
|
+
17, # SYSTEM_MANDATORY_LABEL_ACE_TYPE
|
|
1021
|
+
18, # SYSTEM_RESOURCE_ATTRIBUTE_ACE_TYPE
|
|
1022
|
+
19, # SYSTEM_SCOPED_POLICY_ID_ACE_TYPE
|
|
1023
|
+
20, # SYSTEM_PROCESS_TRUST_LABEL_ACE_TYPE
|
|
1024
|
+
21, # SYSTEM_ACCESS_FILTER_ACE_TYPE
|
|
1025
|
+
}
|
|
1026
|
+
acl = ctypes.cast(dacl, ctypes.POINTER(ACL)).contents
|
|
1027
|
+
for index in range(acl.ace_count):
|
|
1028
|
+
ace_pointer = ctypes.c_void_p()
|
|
1029
|
+
if not advapi32.GetAce(dacl, index, ctypes.byref(ace_pointer)):
|
|
1030
|
+
return False
|
|
1031
|
+
header = ctypes.cast(ace_pointer, ctypes.POINTER(ACE_HEADER)).contents
|
|
1032
|
+
if header.ace_type != 0:
|
|
1033
|
+
if header.ace_type in non_granting_ace_types:
|
|
1034
|
+
continue
|
|
1035
|
+
return False # Unsupported or unknown granting ACE: the writer proof is incomplete.
|
|
1036
|
+
ace = ctypes.cast(ace_pointer, ctypes.POINTER(ACCESS_ALLOWED_ACE)).contents
|
|
1037
|
+
if not ace.mask & write_mask:
|
|
1038
|
+
continue
|
|
1039
|
+
sid = ace_pointer.value + ACCESS_ALLOWED_ACE.sid_start.offset
|
|
1040
|
+
if not any(advapi32.EqualSid(sid, permitted) for permitted in allowed_sids):
|
|
1041
|
+
return False
|
|
1042
|
+
return True
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
__all__ = [
|
|
1046
|
+
"CREDENTIAL_ENVELOPE_SCHEMA",
|
|
1047
|
+
"CredentialBackend",
|
|
1048
|
+
"CredentialEnvelope",
|
|
1049
|
+
"CredentialLease",
|
|
1050
|
+
"CredentialStore",
|
|
1051
|
+
"CredentialStoreError",
|
|
1052
|
+
"ProtectedCredential",
|
|
1053
|
+
"SecureFileBackend",
|
|
1054
|
+
"credential_lock",
|
|
1055
|
+
]
|