apsimo 1.3.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.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
apsimo/chain/keys.py
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
"""Colony key resilience: Shamir Secret Sharing, encrypted share persistence, key rotation.
|
|
2
|
+
|
|
3
|
+
The private key is NEVER stored in plaintext at rest or between signing calls.
|
|
4
|
+
All reconstruction happens in memory and is zeroed immediately after use.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, List, Optional, Protocol, Tuple, Union
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import httpx as _httpx
|
|
21
|
+
except ImportError:
|
|
22
|
+
_httpx = None # type: ignore
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Share primitives
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class KeyShare:
|
|
34
|
+
"""A single encrypted Shamir share."""
|
|
35
|
+
|
|
36
|
+
colony_id: str
|
|
37
|
+
share_index: int # 1-based, range [1, n]
|
|
38
|
+
n: int # total shares
|
|
39
|
+
k: int # reconstruction threshold
|
|
40
|
+
# No defaults — callers must supply explicit salt and nonce so that
|
|
41
|
+
# randomness generation is visible at the call site (SEC-14-L-07)
|
|
42
|
+
argon2_salt: bytes = field()
|
|
43
|
+
aes_nonce: bytes = field()
|
|
44
|
+
version: int = 1
|
|
45
|
+
ciphertext: bytes = b"" # encrypted share bytes
|
|
46
|
+
tag: bytes = b"" # GCM authentication tag
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"version": self.version,
|
|
51
|
+
"colony_id": self.colony_id,
|
|
52
|
+
"share_index": self.share_index,
|
|
53
|
+
"n": self.n,
|
|
54
|
+
"k": self.k,
|
|
55
|
+
"argon2_salt": self.argon2_salt.hex(),
|
|
56
|
+
"aes_nonce": self.aes_nonce.hex(),
|
|
57
|
+
"ciphertext": self.ciphertext.hex(),
|
|
58
|
+
"tag": self.tag.hex(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, d: dict) -> "KeyShare":
|
|
63
|
+
return cls(
|
|
64
|
+
colony_id=d["colony_id"],
|
|
65
|
+
share_index=d["share_index"],
|
|
66
|
+
n=d["n"],
|
|
67
|
+
k=d["k"],
|
|
68
|
+
version=d.get("version", 1),
|
|
69
|
+
argon2_salt=bytes.fromhex(d["argon2_salt"]),
|
|
70
|
+
aes_nonce=bytes.fromhex(d["aes_nonce"]),
|
|
71
|
+
ciphertext=bytes.fromhex(d["ciphertext"]),
|
|
72
|
+
tag=bytes.fromhex(d["tag"]),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ShareBackend(Protocol):
|
|
77
|
+
"""Protocol for key share storage backends."""
|
|
78
|
+
|
|
79
|
+
def store_share(self, share: KeyShare) -> None:
|
|
80
|
+
"""Persist an encrypted share to this backend."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
|
|
84
|
+
"""Retrieve a share. Returns None if not present."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
def delete_share(self, colony_id: str, share_index: int) -> None:
|
|
88
|
+
"""Delete a share (used during rotation)."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
def list_shares(self, colony_id: str) -> List[int]:
|
|
92
|
+
"""Return the list of share indices stored in this backend."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Shamir implementation over GF(2^8)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ShamirKeyManager:
|
|
102
|
+
"""Split and reconstruct Ed25519 private keys using Shamir Secret Sharing.
|
|
103
|
+
|
|
104
|
+
The scheme operates over GF(2^8), applying the polynomial independently
|
|
105
|
+
to each of the 32 bytes of the private key.
|
|
106
|
+
|
|
107
|
+
Requirements:
|
|
108
|
+
- MUST support n in range [3, 20] and k in range [2, n].
|
|
109
|
+
- MUST zero all intermediate key material from memory after use.
|
|
110
|
+
- MUST validate that reconstructed key length matches 32 bytes.
|
|
111
|
+
- MUST raise ValueError if fewer than k shares are provided.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
GF256_EXP: List[int] = []
|
|
115
|
+
GF256_LOG: List[int] = []
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
if not self.GF256_EXP:
|
|
119
|
+
self._init_field_tables()
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def _init_field_tables(cls) -> None:
|
|
123
|
+
"""Precompute GF(2^8) exponential and logarithm tables."""
|
|
124
|
+
cls.GF256_EXP = [0] * 512
|
|
125
|
+
cls.GF256_LOG = [0] * 256
|
|
126
|
+
x = 1
|
|
127
|
+
for i in range(255):
|
|
128
|
+
cls.GF256_EXP[i] = x
|
|
129
|
+
cls.GF256_LOG[x] = i
|
|
130
|
+
x = cls._gf_mul_slow(x, 3)
|
|
131
|
+
for i in range(255, 512):
|
|
132
|
+
cls.GF256_EXP[i] = cls.GF256_EXP[i - 255]
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _gf_mul_slow(a: int, b: int) -> int:
|
|
136
|
+
"""GF(2^8) multiplication with primitive polynomial x^8+x^4+x^3+x+1."""
|
|
137
|
+
p = 0
|
|
138
|
+
for _ in range(8):
|
|
139
|
+
if b & 1:
|
|
140
|
+
p ^= a
|
|
141
|
+
hi = a & 0x80
|
|
142
|
+
a = (a << 1) & 0xFF
|
|
143
|
+
if hi:
|
|
144
|
+
a ^= 0x1B
|
|
145
|
+
b >>= 1
|
|
146
|
+
return p
|
|
147
|
+
|
|
148
|
+
def _gf_mul(self, a: int, b: int) -> int:
|
|
149
|
+
if a == 0 or b == 0:
|
|
150
|
+
return 0
|
|
151
|
+
return self.GF256_EXP[(self.GF256_LOG[a] + self.GF256_LOG[b]) % 255]
|
|
152
|
+
|
|
153
|
+
def _gf_div(self, a: int, b: int) -> int:
|
|
154
|
+
if b == 0:
|
|
155
|
+
raise ZeroDivisionError("GF(2^8) division by zero")
|
|
156
|
+
if a == 0:
|
|
157
|
+
return 0
|
|
158
|
+
return self.GF256_EXP[(self.GF256_LOG[a] - self.GF256_LOG[b]) % 255]
|
|
159
|
+
|
|
160
|
+
def _eval_polynomial(self, coefficients: List[int], x: int) -> int:
|
|
161
|
+
"""Evaluate polynomial at x in GF(2^8)."""
|
|
162
|
+
result = 0
|
|
163
|
+
for coeff in reversed(coefficients):
|
|
164
|
+
result = self._gf_mul(result, x) ^ coeff
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def split(self, key: bytes, n: int, k: int) -> List[tuple]:
|
|
168
|
+
"""Split a 32-byte private key into n shares with threshold k.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
key: 32-byte Ed25519 private key.
|
|
172
|
+
n: Total number of shares to produce.
|
|
173
|
+
k: Minimum shares required for reconstruction.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of (x, share_bytes) tuples where x is in range [1, n].
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If key length != 32, n < 3, k < 2, or k > n.
|
|
180
|
+
"""
|
|
181
|
+
if len(key) != 32:
|
|
182
|
+
raise ValueError(f"Key must be 32 bytes, got {len(key)}")
|
|
183
|
+
if n < 3 or n > 20:
|
|
184
|
+
raise ValueError(f"n must be in [3, 20], got {n}")
|
|
185
|
+
if k < 2 or k > n:
|
|
186
|
+
raise ValueError(f"k must be in [2, n], got k={k} n={n}")
|
|
187
|
+
|
|
188
|
+
shares: List[tuple] = [(x, bytearray()) for x in range(1, n + 1)]
|
|
189
|
+
for byte_pos in range(32):
|
|
190
|
+
coefficients = [key[byte_pos]] + [
|
|
191
|
+
secrets.randbelow(256) for _ in range(k - 1)
|
|
192
|
+
]
|
|
193
|
+
for i, (x, share_buf) in enumerate(shares):
|
|
194
|
+
y = self._eval_polynomial(coefficients, x)
|
|
195
|
+
share_buf.append(y)
|
|
196
|
+
# Zero coefficients
|
|
197
|
+
for i in range(len(coefficients)):
|
|
198
|
+
coefficients[i] = 0
|
|
199
|
+
|
|
200
|
+
return [(x, bytes(buf)) for x, buf in shares]
|
|
201
|
+
|
|
202
|
+
def reconstruct(self, shares: List[tuple], k: int) -> bytes:
|
|
203
|
+
"""Reconstruct the private key from k or more shares.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
shares: List of (x, share_bytes) tuples.
|
|
207
|
+
k: Threshold (used for validation only; any k+ shares reconstruct).
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
32-byte reconstructed private key.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If fewer than k shares provided or share lengths differ.
|
|
214
|
+
"""
|
|
215
|
+
if len(shares) < k:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"Need at least {k} shares to reconstruct, got {len(shares)}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
share_len = len(shares[0][1])
|
|
221
|
+
if any(len(s) != share_len for _, s in shares):
|
|
222
|
+
raise ValueError("All shares must be the same length")
|
|
223
|
+
|
|
224
|
+
key_bytes = bytearray(share_len)
|
|
225
|
+
for byte_pos in range(share_len):
|
|
226
|
+
points = [(x, share[byte_pos]) for x, share in shares]
|
|
227
|
+
key_bytes[byte_pos] = self._lagrange_interpolate(points, 0)
|
|
228
|
+
|
|
229
|
+
return bytes(key_bytes)
|
|
230
|
+
|
|
231
|
+
def _lagrange_interpolate(self, points: List[tuple], x: int) -> int:
|
|
232
|
+
"""Lagrange interpolation at x in GF(2^8)."""
|
|
233
|
+
result = 0
|
|
234
|
+
for i, (xi, yi) in enumerate(points):
|
|
235
|
+
numerator = denominator = 1
|
|
236
|
+
for j, (xj, _) in enumerate(points):
|
|
237
|
+
if i != j:
|
|
238
|
+
numerator = self._gf_mul(numerator, x ^ xj)
|
|
239
|
+
denominator = self._gf_mul(denominator, xi ^ xj)
|
|
240
|
+
lagrange_coeff = self._gf_div(numerator, denominator)
|
|
241
|
+
result ^= self._gf_mul(yi, lagrange_coeff)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def zero_key(self, key: bytearray) -> None:
|
|
245
|
+
"""Zero key material in memory (SEC-13-L-01: portable memoryview approach)."""
|
|
246
|
+
if isinstance(key, bytearray):
|
|
247
|
+
memoryview(key)[:] = b'\x00' * len(key)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Encrypted share persistence
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class KeyShareStore:
|
|
256
|
+
"""Encrypted share persistence using AES-256-GCM + Argon2id.
|
|
257
|
+
|
|
258
|
+
Requirements:
|
|
259
|
+
- MUST encrypt every share before writing to any backend.
|
|
260
|
+
- MUST bind share to its colony_id and share_index via AAD.
|
|
261
|
+
- MUST support multiple backends (local file, remote node, backup file).
|
|
262
|
+
- MUST detect and reject share transplantation (wrong colony_id or index).
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
colony_id: str,
|
|
268
|
+
network_id: str,
|
|
269
|
+
backends: List[ShareBackend],
|
|
270
|
+
) -> None:
|
|
271
|
+
self.colony_id = colony_id
|
|
272
|
+
self.network_id = network_id
|
|
273
|
+
self.backends = backends
|
|
274
|
+
|
|
275
|
+
def _aad(self, share_index: int) -> bytes:
|
|
276
|
+
"""Compute authenticated additional data binding share to context."""
|
|
277
|
+
return (
|
|
278
|
+
bytes.fromhex(self.colony_id)
|
|
279
|
+
+ bytes([share_index])
|
|
280
|
+
+ bytes.fromhex(self.network_id)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def encrypt_share(
|
|
284
|
+
self,
|
|
285
|
+
raw_share: bytes,
|
|
286
|
+
share_index: int,
|
|
287
|
+
n: int,
|
|
288
|
+
k: int,
|
|
289
|
+
passphrase: bytes,
|
|
290
|
+
) -> KeyShare:
|
|
291
|
+
"""Encrypt a raw share blob.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
raw_share: 32-byte raw share from ShamirKeyManager.split().
|
|
295
|
+
share_index: 1-based index of this share.
|
|
296
|
+
n: Total shares in the scheme.
|
|
297
|
+
k: Reconstruction threshold.
|
|
298
|
+
passphrase: Bytes passphrase for Argon2id KDF.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
KeyShare with encrypted ciphertext and tag set.
|
|
302
|
+
"""
|
|
303
|
+
from argon2.low_level import Type, hash_secret_raw
|
|
304
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
305
|
+
|
|
306
|
+
salt = os.urandom(16)
|
|
307
|
+
key = hash_secret_raw(
|
|
308
|
+
secret=passphrase,
|
|
309
|
+
salt=salt,
|
|
310
|
+
time_cost=3,
|
|
311
|
+
memory_cost=65536,
|
|
312
|
+
parallelism=4,
|
|
313
|
+
hash_len=32,
|
|
314
|
+
type=Type.ID,
|
|
315
|
+
)
|
|
316
|
+
nonce = os.urandom(12)
|
|
317
|
+
aesgcm = AESGCM(key)
|
|
318
|
+
aad = self._aad(share_index)
|
|
319
|
+
ct_with_tag = aesgcm.encrypt(nonce, raw_share, aad)
|
|
320
|
+
ciphertext = ct_with_tag[:-16]
|
|
321
|
+
tag = ct_with_tag[-16:]
|
|
322
|
+
|
|
323
|
+
share = KeyShare(
|
|
324
|
+
colony_id=self.colony_id,
|
|
325
|
+
share_index=share_index,
|
|
326
|
+
n=n,
|
|
327
|
+
k=k,
|
|
328
|
+
argon2_salt=salt,
|
|
329
|
+
aes_nonce=nonce,
|
|
330
|
+
ciphertext=ciphertext,
|
|
331
|
+
tag=tag,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Zero derived key (SEC-13-L-01: portable memoryview approach)
|
|
335
|
+
key_buf = bytearray(key)
|
|
336
|
+
memoryview(key_buf)[:] = b'\x00' * len(key_buf)
|
|
337
|
+
|
|
338
|
+
return share
|
|
339
|
+
|
|
340
|
+
def decrypt_share(self, share: KeyShare, passphrase: bytes) -> bytes:
|
|
341
|
+
"""Decrypt a KeyShare back to raw share bytes.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: If colony_id mismatch (transplantation attack).
|
|
345
|
+
cryptography.exceptions.InvalidTag: If decryption fails.
|
|
346
|
+
"""
|
|
347
|
+
if share.colony_id != self.colony_id:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"Share colony_id mismatch: expected {self.colony_id}, "
|
|
350
|
+
f"got {share.colony_id}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
from argon2.low_level import Type, hash_secret_raw
|
|
354
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
355
|
+
|
|
356
|
+
key = hash_secret_raw(
|
|
357
|
+
secret=passphrase,
|
|
358
|
+
salt=share.argon2_salt,
|
|
359
|
+
time_cost=3,
|
|
360
|
+
memory_cost=65536,
|
|
361
|
+
parallelism=4,
|
|
362
|
+
hash_len=32,
|
|
363
|
+
type=Type.ID,
|
|
364
|
+
)
|
|
365
|
+
aesgcm = AESGCM(key)
|
|
366
|
+
aad = self._aad(share.share_index)
|
|
367
|
+
raw = aesgcm.decrypt(
|
|
368
|
+
share.aes_nonce,
|
|
369
|
+
share.ciphertext + share.tag,
|
|
370
|
+
aad,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Zero derived key (SEC-13-L-01: portable memoryview approach)
|
|
374
|
+
key_buf = bytearray(key)
|
|
375
|
+
memoryview(key_buf)[:] = b'\x00' * len(key_buf)
|
|
376
|
+
|
|
377
|
+
return raw
|
|
378
|
+
|
|
379
|
+
def distribute(self, encrypted_shares: List[KeyShare]) -> None:
|
|
380
|
+
"""Distribute encrypted shares across configured backends round-robin."""
|
|
381
|
+
for i, share in enumerate(encrypted_shares):
|
|
382
|
+
backend = self.backends[i % len(self.backends)]
|
|
383
|
+
backend.store_share(share)
|
|
384
|
+
|
|
385
|
+
def collect(
|
|
386
|
+
self, k: int, passphrase_fn: Callable[[int], bytes]
|
|
387
|
+
) -> List[tuple]:
|
|
388
|
+
"""Collect and decrypt at least k shares from available backends.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
k: Minimum shares to collect.
|
|
392
|
+
passphrase_fn: Callable(share_index) -> passphrase bytes.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
List of (share_index, raw_share_bytes) tuples.
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
RuntimeError: If fewer than k shares can be collected.
|
|
399
|
+
"""
|
|
400
|
+
raw_shares: List[tuple] = []
|
|
401
|
+
seen_indices: set = set()
|
|
402
|
+
|
|
403
|
+
for backend in self.backends:
|
|
404
|
+
indices = backend.list_shares(self.colony_id)
|
|
405
|
+
for idx in indices:
|
|
406
|
+
if idx in seen_indices:
|
|
407
|
+
continue
|
|
408
|
+
share = backend.retrieve_share(self.colony_id, idx)
|
|
409
|
+
if share is None:
|
|
410
|
+
continue
|
|
411
|
+
passphrase = passphrase_fn(idx)
|
|
412
|
+
try:
|
|
413
|
+
raw = self.decrypt_share(share, passphrase)
|
|
414
|
+
raw_shares.append((idx, raw))
|
|
415
|
+
seen_indices.add(idx)
|
|
416
|
+
except Exception:
|
|
417
|
+
continue
|
|
418
|
+
if len(raw_shares) >= k:
|
|
419
|
+
break
|
|
420
|
+
if len(raw_shares) >= k:
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
if len(raw_shares) < k:
|
|
424
|
+
raise RuntimeError(
|
|
425
|
+
f"Could only collect {len(raw_shares)} shares, need {k}. "
|
|
426
|
+
"Check that enough share backends are reachable."
|
|
427
|
+
)
|
|
428
|
+
return raw_shares
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
# High-level key manager
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class ColonyKeyManager:
|
|
437
|
+
"""High-level manager for colony signing operations.
|
|
438
|
+
|
|
439
|
+
Owns the split/reconstruct/sign lifecycle. The private key is NEVER
|
|
440
|
+
stored on this object between signing calls.
|
|
441
|
+
|
|
442
|
+
Requirements:
|
|
443
|
+
- MUST reconstruct key in memory only.
|
|
444
|
+
- MUST zero key immediately after signing.
|
|
445
|
+
- MUST NOT cache the reconstructed private key between calls.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
def __init__(
|
|
449
|
+
self,
|
|
450
|
+
colony_id: str,
|
|
451
|
+
shamir: ShamirKeyManager,
|
|
452
|
+
store: KeyShareStore,
|
|
453
|
+
k: int,
|
|
454
|
+
passphrase_fn: Callable[[int], bytes],
|
|
455
|
+
audit_log: Optional[Callable[[str], None]] = None,
|
|
456
|
+
) -> None:
|
|
457
|
+
self.colony_id = colony_id
|
|
458
|
+
self.shamir = shamir
|
|
459
|
+
self.store = store
|
|
460
|
+
self.k = k
|
|
461
|
+
self.passphrase_fn = passphrase_fn
|
|
462
|
+
self._audit = audit_log or (lambda msg: None)
|
|
463
|
+
|
|
464
|
+
def sign(self, payload: bytes) -> str:
|
|
465
|
+
"""Sign payload bytes, returning hex-encoded Ed25519 signature."""
|
|
466
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
467
|
+
Ed25519PrivateKey,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
self._audit(f"key_reconstruct colony={self.colony_id}")
|
|
471
|
+
raw_shares = self.store.collect(self.k, self.passphrase_fn)
|
|
472
|
+
raw_key = bytearray(
|
|
473
|
+
self.shamir.reconstruct([(idx, s) for idx, s in raw_shares], self.k)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
private_key = Ed25519PrivateKey.from_private_bytes(bytes(raw_key))
|
|
478
|
+
signature = private_key.sign(payload)
|
|
479
|
+
return signature.hex()
|
|
480
|
+
finally:
|
|
481
|
+
self.shamir.zero_key(raw_key)
|
|
482
|
+
self._audit(f"key_zeroed colony={self.colony_id}")
|
|
483
|
+
|
|
484
|
+
def public_key_hex(self) -> str:
|
|
485
|
+
"""Derive public key from reconstructed private key."""
|
|
486
|
+
from cryptography.hazmat.primitives import serialization
|
|
487
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
488
|
+
Ed25519PrivateKey,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
raw_shares = self.store.collect(self.k, self.passphrase_fn)
|
|
492
|
+
raw_key = bytearray(
|
|
493
|
+
self.shamir.reconstruct([(idx, s) for idx, s in raw_shares], self.k)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
private_key = Ed25519PrivateKey.from_private_bytes(bytes(raw_key))
|
|
498
|
+
pub = private_key.public_key().public_bytes(
|
|
499
|
+
encoding=serialization.Encoding.Raw,
|
|
500
|
+
format=serialization.PublicFormat.Raw,
|
|
501
|
+
)
|
|
502
|
+
return pub.hex()
|
|
503
|
+
finally:
|
|
504
|
+
self.shamir.zero_key(raw_key)
|
|
505
|
+
|
|
506
|
+
def rotate(
|
|
507
|
+
self,
|
|
508
|
+
new_private_key: bytes,
|
|
509
|
+
n: int,
|
|
510
|
+
passphrase_fn: Optional[Callable[[int], bytes]] = None,
|
|
511
|
+
) -> str:
|
|
512
|
+
"""Rotate to a new private key: split, encrypt, distribute.
|
|
513
|
+
|
|
514
|
+
Signs a colony_rotate_key payload with the OLD key first,
|
|
515
|
+
then replaces shares. Returns hex-encoded new public key.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
new_private_key: 32-byte new Ed25519 private key.
|
|
519
|
+
n: Number of shares for the new scheme.
|
|
520
|
+
passphrase_fn: Passphrase callable for new shares (defaults to existing).
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Hex-encoded new public key.
|
|
524
|
+
"""
|
|
525
|
+
from cryptography.hazmat.primitives import serialization
|
|
526
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
527
|
+
Ed25519PrivateKey,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
pf = passphrase_fn or self.passphrase_fn
|
|
531
|
+
|
|
532
|
+
# Derive new public key
|
|
533
|
+
new_pk = Ed25519PrivateKey.from_private_bytes(new_private_key)
|
|
534
|
+
new_pubkey_hex = new_pk.public_key().public_bytes(
|
|
535
|
+
encoding=serialization.Encoding.Raw,
|
|
536
|
+
format=serialization.PublicFormat.Raw,
|
|
537
|
+
).hex()
|
|
538
|
+
|
|
539
|
+
# Split new key and encrypt shares
|
|
540
|
+
new_shares = self.shamir.split(new_private_key, n, self.k)
|
|
541
|
+
encrypted: List[KeyShare] = []
|
|
542
|
+
for x, raw_share in new_shares:
|
|
543
|
+
enc = self.store.encrypt_share(raw_share, x, n, self.k, pf(x))
|
|
544
|
+
encrypted.append(enc)
|
|
545
|
+
|
|
546
|
+
# Delete old shares from all backends
|
|
547
|
+
old_indices = []
|
|
548
|
+
for backend in self.store.backends:
|
|
549
|
+
old_indices.extend(backend.list_shares(self.colony_id))
|
|
550
|
+
for backend in self.store.backends:
|
|
551
|
+
for idx in set(old_indices):
|
|
552
|
+
backend.delete_share(self.colony_id, idx)
|
|
553
|
+
|
|
554
|
+
# Distribute new shares
|
|
555
|
+
self.store.distribute(encrypted)
|
|
556
|
+
|
|
557
|
+
# Update passphrase_fn if new one was provided
|
|
558
|
+
if passphrase_fn is not None:
|
|
559
|
+
self.passphrase_fn = passphrase_fn
|
|
560
|
+
|
|
561
|
+
logger.info("Key rotation complete for colony %s", self.colony_id)
|
|
562
|
+
return new_pubkey_hex
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
# Share backends
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class LocalFileShareBackend:
|
|
571
|
+
"""Stores encrypted shares as JSON files on the local filesystem.
|
|
572
|
+
|
|
573
|
+
Share files are stored at: data_dir / "shares" / f"share_{index:02d}.json"
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
def __init__(self, data_dir: Path) -> None:
|
|
577
|
+
self.data_dir = data_dir
|
|
578
|
+
self.shares_dir = data_dir / "shares"
|
|
579
|
+
self.shares_dir.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
|
|
581
|
+
def store_share(self, share: KeyShare) -> None:
|
|
582
|
+
path = self.shares_dir / f"share_{share.share_index:02d}.json"
|
|
583
|
+
path.write_text(json.dumps(share.to_dict(), indent=2))
|
|
584
|
+
path.chmod(0o600)
|
|
585
|
+
|
|
586
|
+
def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
|
|
587
|
+
path = self.shares_dir / f"share_{share_index:02d}.json"
|
|
588
|
+
if not path.exists():
|
|
589
|
+
return None
|
|
590
|
+
return KeyShare.from_dict(json.loads(path.read_text()))
|
|
591
|
+
|
|
592
|
+
def delete_share(self, colony_id: str, share_index: int) -> None:
|
|
593
|
+
path = self.shares_dir / f"share_{share_index:02d}.json"
|
|
594
|
+
if path.exists():
|
|
595
|
+
# Overwrite with random bytes before deletion
|
|
596
|
+
path.write_bytes(os.urandom(path.stat().st_size))
|
|
597
|
+
path.unlink()
|
|
598
|
+
|
|
599
|
+
def list_shares(self, colony_id: str) -> List[int]:
|
|
600
|
+
indices = []
|
|
601
|
+
for f in self.shares_dir.glob("share_*.json"):
|
|
602
|
+
try:
|
|
603
|
+
indices.append(int(f.stem.split("_")[1]))
|
|
604
|
+
except (IndexError, ValueError):
|
|
605
|
+
continue
|
|
606
|
+
return sorted(indices)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class RemoteNodeShareBackend:
|
|
610
|
+
"""Stores encrypted shares on a remote mesh node via mTLS HTTP API.
|
|
611
|
+
|
|
612
|
+
Uses httpx with mutual TLS (client cert + server CA verification) for
|
|
613
|
+
authenticated share transport across Colony mesh nodes.
|
|
614
|
+
|
|
615
|
+
API contract (served by the remote node at BASE_PATH):
|
|
616
|
+
PUT /{colony_id}/{share_index} — store or replace a share
|
|
617
|
+
GET /{colony_id}/{share_index} — retrieve a share (404 → None)
|
|
618
|
+
DELETE /{colony_id}/{share_index} — delete a share (404 → silent)
|
|
619
|
+
GET /{colony_id} — list indices {"indices": [...]}
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
node_id: Identifier for the remote peer (used in log messages).
|
|
623
|
+
endpoint: Base URL of the remote node, e.g. ``https://host:9443``.
|
|
624
|
+
client_cert: mTLS client identity — ``(cert_path, key_path)`` or
|
|
625
|
+
``(cert_path, key_path, password)``. ``None`` disables
|
|
626
|
+
client-side certificate.
|
|
627
|
+
ca_cert: Path to the CA bundle used to verify the server cert,
|
|
628
|
+
``True`` (default trust store) or ``False`` (skip verify).
|
|
629
|
+
``None`` leaves httpx at its default (True).
|
|
630
|
+
timeout: Per-request timeout in seconds.
|
|
631
|
+
max_retries: Number of attempts before giving up on transient errors.
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
BASE_PATH = "/internal/shares"
|
|
635
|
+
DEFAULT_TIMEOUT: float = 10.0
|
|
636
|
+
DEFAULT_MAX_RETRIES: int = 3
|
|
637
|
+
RETRY_BACKOFF_BASE: float = 0.5 # seconds; doubles each attempt
|
|
638
|
+
|
|
639
|
+
def __init__(
|
|
640
|
+
self,
|
|
641
|
+
node_id: str,
|
|
642
|
+
endpoint: str,
|
|
643
|
+
client_cert: Optional[Union[Tuple[str, str], Tuple[str, str, str]]] = None,
|
|
644
|
+
ca_cert: Optional[Union[str, bool]] = None,
|
|
645
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
646
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
647
|
+
# Legacy kwarg accepted but ignored — mTLS is now configured via
|
|
648
|
+
# client_cert / ca_cert.
|
|
649
|
+
mtls_ctx=None,
|
|
650
|
+
) -> None:
|
|
651
|
+
if _httpx is None:
|
|
652
|
+
raise RuntimeError(
|
|
653
|
+
"httpx is required for RemoteNodeShareBackend. "
|
|
654
|
+
"Install it with: pip install httpx"
|
|
655
|
+
)
|
|
656
|
+
self.node_id = node_id
|
|
657
|
+
self.endpoint = endpoint.rstrip("/")
|
|
658
|
+
self._client_cert = client_cert
|
|
659
|
+
self._ca_cert = ca_cert
|
|
660
|
+
self._timeout = timeout
|
|
661
|
+
self._max_retries = max_retries
|
|
662
|
+
self._client: Optional["_httpx.Client"] = None
|
|
663
|
+
|
|
664
|
+
# ------------------------------------------------------------------
|
|
665
|
+
# Client lifecycle
|
|
666
|
+
# ------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
def _build_client(self) -> "_httpx.Client":
|
|
669
|
+
"""Construct an httpx.Client with the configured mTLS parameters."""
|
|
670
|
+
kwargs: dict = {"timeout": self._timeout}
|
|
671
|
+
if self._ca_cert is not None:
|
|
672
|
+
kwargs["verify"] = self._ca_cert
|
|
673
|
+
if self._client_cert is not None:
|
|
674
|
+
kwargs["cert"] = self._client_cert
|
|
675
|
+
return _httpx.Client(**kwargs)
|
|
676
|
+
|
|
677
|
+
@property
|
|
678
|
+
def _http(self) -> "_httpx.Client":
|
|
679
|
+
"""Return the active client, rebuilding lazily if needed."""
|
|
680
|
+
if self._client is None or self._client.is_closed:
|
|
681
|
+
self._client = self._build_client()
|
|
682
|
+
return self._client
|
|
683
|
+
|
|
684
|
+
def rotate_certificates(
|
|
685
|
+
self,
|
|
686
|
+
client_cert: Optional[Union[Tuple[str, str], Tuple[str, str, str]]] = None,
|
|
687
|
+
ca_cert: Optional[Union[str, bool]] = None,
|
|
688
|
+
) -> None:
|
|
689
|
+
"""Swap TLS certificates. The next request will use the new certs.
|
|
690
|
+
|
|
691
|
+
Only the provided arguments are updated; omit a parameter to leave it
|
|
692
|
+
unchanged.
|
|
693
|
+
"""
|
|
694
|
+
if client_cert is not None:
|
|
695
|
+
self._client_cert = client_cert
|
|
696
|
+
if ca_cert is not None:
|
|
697
|
+
self._ca_cert = ca_cert
|
|
698
|
+
# Tear down the existing client; new one is built lazily.
|
|
699
|
+
if self._client is not None and not self._client.is_closed:
|
|
700
|
+
self._client.close()
|
|
701
|
+
self._client = None
|
|
702
|
+
|
|
703
|
+
def close(self) -> None:
|
|
704
|
+
"""Close the underlying HTTP client."""
|
|
705
|
+
if self._client is not None and not self._client.is_closed:
|
|
706
|
+
self._client.close()
|
|
707
|
+
self._client = None
|
|
708
|
+
|
|
709
|
+
def __enter__(self) -> "RemoteNodeShareBackend":
|
|
710
|
+
return self
|
|
711
|
+
|
|
712
|
+
def __exit__(self, *_) -> None:
|
|
713
|
+
self.close()
|
|
714
|
+
|
|
715
|
+
# ------------------------------------------------------------------
|
|
716
|
+
# Internal request helper with exponential-backoff retry
|
|
717
|
+
# ------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
def _request(self, method: str, path: str, **kwargs) -> "_httpx.Response":
|
|
720
|
+
"""Execute a request against the remote node, retrying on transient failures.
|
|
721
|
+
|
|
722
|
+
4xx responses are *not* retried (caller error).
|
|
723
|
+
5xx, timeouts, and network errors are retried up to ``max_retries`` times.
|
|
724
|
+
|
|
725
|
+
Raises:
|
|
726
|
+
httpx.HTTPStatusError: On a non-retryable HTTP error (4xx).
|
|
727
|
+
RuntimeError: When all retry attempts are exhausted.
|
|
728
|
+
"""
|
|
729
|
+
url = f"{self.endpoint}{path}"
|
|
730
|
+
last_exc: Optional[Exception] = None
|
|
731
|
+
|
|
732
|
+
for attempt in range(self._max_retries):
|
|
733
|
+
try:
|
|
734
|
+
resp = self._http.request(method, url, **kwargs)
|
|
735
|
+
resp.raise_for_status()
|
|
736
|
+
return resp
|
|
737
|
+
except _httpx.HTTPStatusError as exc:
|
|
738
|
+
if exc.response.status_code < 500:
|
|
739
|
+
raise # 4xx — don't retry
|
|
740
|
+
last_exc = exc
|
|
741
|
+
logger.warning(
|
|
742
|
+
"Server error (attempt %d/%d) %s %s: HTTP %d",
|
|
743
|
+
attempt + 1, self._max_retries, method, url,
|
|
744
|
+
exc.response.status_code,
|
|
745
|
+
)
|
|
746
|
+
except _httpx.TimeoutException as exc:
|
|
747
|
+
last_exc = exc
|
|
748
|
+
logger.warning(
|
|
749
|
+
"Timeout (attempt %d/%d) %s %s",
|
|
750
|
+
attempt + 1, self._max_retries, method, url,
|
|
751
|
+
)
|
|
752
|
+
except _httpx.NetworkError as exc:
|
|
753
|
+
last_exc = exc
|
|
754
|
+
logger.warning(
|
|
755
|
+
"Network error (attempt %d/%d) %s %s: %s",
|
|
756
|
+
attempt + 1, self._max_retries, method, url, exc,
|
|
757
|
+
)
|
|
758
|
+
# Recreate client — the connection may be in a broken state.
|
|
759
|
+
if self._client is not None and not self._client.is_closed:
|
|
760
|
+
self._client.close()
|
|
761
|
+
self._client = None
|
|
762
|
+
|
|
763
|
+
if attempt < self._max_retries - 1:
|
|
764
|
+
time.sleep(self.RETRY_BACKOFF_BASE * (2 ** attempt))
|
|
765
|
+
|
|
766
|
+
raise RuntimeError(
|
|
767
|
+
f"Remote share backend '{self.node_id}' unreachable after "
|
|
768
|
+
f"{self._max_retries} attempt(s): {last_exc}"
|
|
769
|
+
) from last_exc
|
|
770
|
+
|
|
771
|
+
# ------------------------------------------------------------------
|
|
772
|
+
# ShareBackend interface
|
|
773
|
+
# ------------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
def store_share(self, share: KeyShare) -> None:
|
|
776
|
+
"""Persist a share to the remote node via PUT."""
|
|
777
|
+
try:
|
|
778
|
+
self._request(
|
|
779
|
+
"PUT",
|
|
780
|
+
f"{self.BASE_PATH}/{share.colony_id}/{share.share_index}",
|
|
781
|
+
json=share.to_dict(),
|
|
782
|
+
)
|
|
783
|
+
except Exception as exc:
|
|
784
|
+
logger.error(
|
|
785
|
+
"store_share failed [node=%s colony=%s index=%d]: %s",
|
|
786
|
+
self.node_id, share.colony_id, share.share_index, exc,
|
|
787
|
+
)
|
|
788
|
+
raise
|
|
789
|
+
|
|
790
|
+
def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
|
|
791
|
+
"""Retrieve a share from the remote node. Returns ``None`` if absent."""
|
|
792
|
+
try:
|
|
793
|
+
resp = self._request(
|
|
794
|
+
"GET",
|
|
795
|
+
f"{self.BASE_PATH}/{colony_id}/{share_index}",
|
|
796
|
+
)
|
|
797
|
+
return KeyShare.from_dict(resp.json())
|
|
798
|
+
except _httpx.HTTPStatusError as exc:
|
|
799
|
+
if exc.response.status_code == 404:
|
|
800
|
+
return None
|
|
801
|
+
logger.error(
|
|
802
|
+
"retrieve_share failed [node=%s colony=%s index=%d]: HTTP %d",
|
|
803
|
+
self.node_id, colony_id, share_index, exc.response.status_code,
|
|
804
|
+
)
|
|
805
|
+
return None
|
|
806
|
+
except Exception as exc:
|
|
807
|
+
logger.error(
|
|
808
|
+
"retrieve_share failed [node=%s colony=%s index=%d]: %s",
|
|
809
|
+
self.node_id, colony_id, share_index, exc,
|
|
810
|
+
)
|
|
811
|
+
return None
|
|
812
|
+
|
|
813
|
+
def delete_share(self, colony_id: str, share_index: int) -> None:
|
|
814
|
+
"""Delete a share from the remote node. 404 is silently ignored."""
|
|
815
|
+
try:
|
|
816
|
+
self._request(
|
|
817
|
+
"DELETE",
|
|
818
|
+
f"{self.BASE_PATH}/{colony_id}/{share_index}",
|
|
819
|
+
)
|
|
820
|
+
except _httpx.HTTPStatusError as exc:
|
|
821
|
+
if exc.response.status_code == 404:
|
|
822
|
+
return # Already absent — treat as success.
|
|
823
|
+
logger.error(
|
|
824
|
+
"delete_share failed [node=%s colony=%s index=%d]: HTTP %d",
|
|
825
|
+
self.node_id, colony_id, share_index, exc.response.status_code,
|
|
826
|
+
)
|
|
827
|
+
raise
|
|
828
|
+
except Exception as exc:
|
|
829
|
+
logger.error(
|
|
830
|
+
"delete_share failed [node=%s colony=%s index=%d]: %s",
|
|
831
|
+
self.node_id, colony_id, share_index, exc,
|
|
832
|
+
)
|
|
833
|
+
raise
|
|
834
|
+
|
|
835
|
+
def list_shares(self, colony_id: str) -> List[int]:
|
|
836
|
+
"""Return sorted share indices held by the remote node.
|
|
837
|
+
|
|
838
|
+
Returns an empty list on any transport failure so callers degrade
|
|
839
|
+
gracefully during network partitions.
|
|
840
|
+
"""
|
|
841
|
+
try:
|
|
842
|
+
resp = self._request("GET", f"{self.BASE_PATH}/{colony_id}")
|
|
843
|
+
return sorted(resp.json().get("indices", []))
|
|
844
|
+
except Exception as exc:
|
|
845
|
+
logger.error(
|
|
846
|
+
"list_shares failed [node=%s colony=%s]: %s",
|
|
847
|
+
self.node_id, colony_id, exc,
|
|
848
|
+
)
|
|
849
|
+
return []
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
class BackupFileShareBackend:
|
|
853
|
+
"""Read/write a portable .colonyshare backup file.
|
|
854
|
+
|
|
855
|
+
Holds exactly one share. Writes are atomic (tmp + os.replace) and
|
|
856
|
+
include a SHA-256 checksum of the share payload so tampering can be
|
|
857
|
+
detected on read.
|
|
858
|
+
|
|
859
|
+
``colony_id`` and ``network_id`` must be provided either at
|
|
860
|
+
construction or via an existing file on disk; otherwise ``store_share``
|
|
861
|
+
cannot build a valid payload.
|
|
862
|
+
"""
|
|
863
|
+
|
|
864
|
+
_FILE_TYPE = "colony_key_share"
|
|
865
|
+
_FILE_VERSION = 2 # v2 adds checksum
|
|
866
|
+
|
|
867
|
+
def __init__(
|
|
868
|
+
self,
|
|
869
|
+
file_path: Path,
|
|
870
|
+
colony_id: Optional[str] = None,
|
|
871
|
+
network_id: Optional[str] = None,
|
|
872
|
+
*,
|
|
873
|
+
overwrite: bool = False,
|
|
874
|
+
) -> None:
|
|
875
|
+
self.file_path = Path(file_path)
|
|
876
|
+
self._share: Optional[KeyShare] = None
|
|
877
|
+
self._colony_id = colony_id
|
|
878
|
+
self._network_id = network_id
|
|
879
|
+
self._overwrite = overwrite
|
|
880
|
+
|
|
881
|
+
@staticmethod
|
|
882
|
+
def _checksum(share_dict: dict) -> str:
|
|
883
|
+
import hashlib
|
|
884
|
+
payload = json.dumps(share_dict, sort_keys=True).encode("utf-8")
|
|
885
|
+
return hashlib.sha256(payload).hexdigest()
|
|
886
|
+
|
|
887
|
+
def _load(self) -> None:
|
|
888
|
+
if self._share is None and self.file_path.exists():
|
|
889
|
+
data = json.loads(self.file_path.read_text())
|
|
890
|
+
share_dict = data["share"]
|
|
891
|
+
# If a checksum is present (v2+), verify it.
|
|
892
|
+
stored = data.get("checksum")
|
|
893
|
+
if stored is not None and stored != self._checksum(share_dict):
|
|
894
|
+
raise ValueError(
|
|
895
|
+
f"Checksum mismatch in backup file {self.file_path}"
|
|
896
|
+
)
|
|
897
|
+
self._share = KeyShare.from_dict(share_dict)
|
|
898
|
+
# Populate colony/network IDs from file if not set at ctor.
|
|
899
|
+
if self._colony_id is None:
|
|
900
|
+
self._colony_id = data.get("colony_id")
|
|
901
|
+
if self._network_id is None:
|
|
902
|
+
self._network_id = data.get("network_id")
|
|
903
|
+
|
|
904
|
+
def store_share(self, share: KeyShare) -> None:
|
|
905
|
+
"""Atomically write ``share`` to the backup file.
|
|
906
|
+
|
|
907
|
+
Refuses to overwrite a file holding a *different* share_index
|
|
908
|
+
unless the backend was constructed with ``overwrite=True``.
|
|
909
|
+
"""
|
|
910
|
+
# Resolve metadata — prefer ctor values, fall back to existing file.
|
|
911
|
+
if self._colony_id is None or self._network_id is None:
|
|
912
|
+
self._load()
|
|
913
|
+
if self._colony_id is None or self._network_id is None:
|
|
914
|
+
raise ValueError(
|
|
915
|
+
"colony_id and network_id are required to write a backup file"
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
if self.file_path.exists() and not self._overwrite:
|
|
919
|
+
try:
|
|
920
|
+
existing = json.loads(self.file_path.read_text())
|
|
921
|
+
existing_index = existing.get("share", {}).get("share_index")
|
|
922
|
+
if existing_index is not None and existing_index != share.share_index:
|
|
923
|
+
raise ValueError(
|
|
924
|
+
f"Backup already holds share_index={existing_index}; "
|
|
925
|
+
f"refusing to overwrite with share_index={share.share_index}"
|
|
926
|
+
)
|
|
927
|
+
except (json.JSONDecodeError, KeyError):
|
|
928
|
+
pass # Corrupt file; will be replaced.
|
|
929
|
+
|
|
930
|
+
share_dict = share.to_dict()
|
|
931
|
+
data = {
|
|
932
|
+
"file_type": self._FILE_TYPE,
|
|
933
|
+
"file_version": self._FILE_VERSION,
|
|
934
|
+
"colony_id": self._colony_id,
|
|
935
|
+
"network_id": self._network_id,
|
|
936
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
937
|
+
"share": share_dict,
|
|
938
|
+
"checksum": self._checksum(share_dict),
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
tmp_path = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
|
|
942
|
+
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
943
|
+
tmp_path.write_text(json.dumps(data, indent=2))
|
|
944
|
+
tmp_path.chmod(0o600)
|
|
945
|
+
os.replace(tmp_path, self.file_path)
|
|
946
|
+
self._share = share
|
|
947
|
+
|
|
948
|
+
def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
|
|
949
|
+
self._load()
|
|
950
|
+
if self._share and self._share.share_index == share_index:
|
|
951
|
+
return self._share
|
|
952
|
+
return None
|
|
953
|
+
|
|
954
|
+
def delete_share(self, colony_id: str, share_index: int) -> None:
|
|
955
|
+
self._load()
|
|
956
|
+
if self._share and self._share.share_index == share_index:
|
|
957
|
+
try:
|
|
958
|
+
self.file_path.unlink()
|
|
959
|
+
except FileNotFoundError:
|
|
960
|
+
pass
|
|
961
|
+
self._share = None
|
|
962
|
+
|
|
963
|
+
def list_shares(self, colony_id: str) -> List[int]:
|
|
964
|
+
self._load()
|
|
965
|
+
if self._share:
|
|
966
|
+
return [self._share.share_index]
|
|
967
|
+
return []
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
class InMemoryShareBackend:
|
|
971
|
+
"""In-memory backend for testing."""
|
|
972
|
+
|
|
973
|
+
def __init__(self) -> None:
|
|
974
|
+
self._shares: dict = {} # share_index -> KeyShare
|
|
975
|
+
|
|
976
|
+
def store_share(self, share: KeyShare) -> None:
|
|
977
|
+
self._shares[share.share_index] = share
|
|
978
|
+
|
|
979
|
+
def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
|
|
980
|
+
return self._shares.get(share_index)
|
|
981
|
+
|
|
982
|
+
def delete_share(self, colony_id: str, share_index: int) -> None:
|
|
983
|
+
self._shares.pop(share_index, None)
|
|
984
|
+
|
|
985
|
+
def list_shares(self, colony_id: str) -> List[int]:
|
|
986
|
+
return sorted(self._shares.keys())
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# ---------------------------------------------------------------------------
|
|
990
|
+
# Backup file helpers
|
|
991
|
+
# ---------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def export_share_file(
|
|
995
|
+
share: KeyShare,
|
|
996
|
+
colony_id: str,
|
|
997
|
+
network_id: str,
|
|
998
|
+
output_path: Path,
|
|
999
|
+
) -> None:
|
|
1000
|
+
"""Write a .colonyshare backup file."""
|
|
1001
|
+
data = {
|
|
1002
|
+
"file_type": "colony_key_share",
|
|
1003
|
+
"file_version": 1,
|
|
1004
|
+
"colony_id": colony_id,
|
|
1005
|
+
"network_id": network_id,
|
|
1006
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
1007
|
+
"share": share.to_dict(),
|
|
1008
|
+
}
|
|
1009
|
+
output_path.write_text(json.dumps(data, indent=2))
|
|
1010
|
+
output_path.chmod(0o600)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def import_share_file(file_path: Path) -> tuple:
|
|
1014
|
+
"""Read a .colonyshare backup file.
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
(colony_id, network_id, KeyShare) tuple.
|
|
1018
|
+
"""
|
|
1019
|
+
data = json.loads(file_path.read_text())
|
|
1020
|
+
if data.get("file_type") != "colony_key_share":
|
|
1021
|
+
raise ValueError(f"Not a colony_key_share file: {file_path}")
|
|
1022
|
+
colony_id = data["colony_id"]
|
|
1023
|
+
network_id = data["network_id"]
|
|
1024
|
+
share = KeyShare.from_dict(data["share"])
|
|
1025
|
+
return colony_id, network_id, share
|