mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Add agent autonomy controls, docs updates, and landing page agent section
This commit is contained in:
1 parent
baaaa25e45
commit
68cb1169e3
35 files changed
+9777
-78
No files matched your search
@@ -29,6 +29,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Agent and Automation Security Features](#agent-and-automation-security-features)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
@@ -83,6 +84,30 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
A small on-screen notification area now shows queued messages for 10 seconds
|
||||
before fading.
|
||||
|
||||
## Agent and Automation Security Features
|
||||
|
||||
SeedPass includes an agent-focused CLI surface for non-interactive automation
|
||||
with policy and security guardrails:
|
||||
|
||||
- **Auth brokers (no plaintext env required):** `env`, `keyring`, `command`, `prompt` providers for `agent init/get`, `vault unlock`, and `api start --unlock`.
|
||||
- **Policy as code:** `agent policy-lint`, `agent policy-review`, and `agent policy-apply` for validated, diffable, risk-gated policy updates.
|
||||
- **Scoped tokens + identities:** `agent token-issue/list/revoke` and `agent identity-create/list/revoke` with TTL, use limits, and revocation.
|
||||
- **One-time/N-use secret leases:** `agent get --lease-only`, `agent lease-consume`, `agent lease-list`, `agent lease-revoke`.
|
||||
- **Approval gates for risky actions:** `agent approval-issue/list/revoke` gates export, parent-seed reveal, and sensitive private-key retrieval flows.
|
||||
- **Secret-class isolation:** dedicated high-risk unlock factor and lock state via `agent high-risk-factor-set`, `agent high-risk-unlock/status/lock`.
|
||||
- **Safer automation primitives:** `agent job-run`, `agent job-template`, and job profiles with policy-stamp checks and signed template manifests.
|
||||
- **Recovery hardening:** `agent recovery-split/recover/drill/drill-list` for Shamir split/recover and signed drill evidence.
|
||||
- **Deterministic export controls:** policy-filtered exports plus manifest integrity checks with `agent export-check` and `agent export-manifest-verify`.
|
||||
- **Security posture tooling:** `agent posture-check` and `agent posture-remediate` for drift detection and remediation bundles.
|
||||
- **Auditable access chain:** append-only HMAC-chained audit records with verification support.
|
||||
|
||||
For machine-readable discovery, run:
|
||||
|
||||
```bash
|
||||
seedpass capabilities --format json
|
||||
seedpass agent bootstrap-context
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
SeedPass follows a layered design. The **`seedpass.core`** package exposes the
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This directory contains SeedPass project documentation.
|
||||
|
||||
## Agent and Autonomy Docs
|
||||
|
||||
- `agent_autonomy_security_plan.md`
|
||||
- `policy_as_code.md`
|
||||
- `sync_conflict_contract.md`
|
||||
|
||||
## Core Security Docs
|
||||
|
||||
- `security_readiness_checklist.md`
|
||||
@@ -26,3 +32,10 @@ This directory contains SeedPass project documentation.
|
||||
- `installer_testing.md`
|
||||
- `ci-health-2026-02-19.md`
|
||||
- `agent-handoffs/README.md`
|
||||
|
||||
## Discoverability Commands
|
||||
|
||||
- `seedpass --help`
|
||||
- `seedpass <group> --help`
|
||||
- `seedpass capabilities --format json`
|
||||
- `seedpass agent bootstrap-context`
|
||||
@@ -30,6 +30,25 @@ SeedPass synchronizes profiles over Nostr using three event kinds:
|
||||
|
||||
Events encode JSON and include tags for checksums, fingerprints, and timestamps.
|
||||
|
||||
## Deterministic Conflict Resolution
|
||||
|
||||
When index payloads are merged (for example during Nostr delta replay), SeedPass
|
||||
uses deterministic conflict semantics with strategy
|
||||
`modified_ts_hash_tombstone_v2`.
|
||||
|
||||
High-level rules:
|
||||
|
||||
- Higher `modified_ts` entries win.
|
||||
- Equal `modified_ts` entries use deterministic hash tie-breakers and
|
||||
field-level union rules for selected metadata.
|
||||
- Deletions are represented as tombstones in `_sync_meta.tombstones` and win
|
||||
against older entry states.
|
||||
- `_sync_meta.last_merge_ts` is derived from payload state, not wall-clock
|
||||
time, so replaying the same payload is idempotent.
|
||||
|
||||
See [sync_conflict_contract.md](sync_conflict_contract.md) for the formal
|
||||
merge/tombstone contract and replay guarantees.
|
||||
|
||||
## Versioning
|
||||
|
||||
Configuration and KDF schemas are versioned so clients can migrate older
|
||||
|
||||
@@ -11,6 +11,12 @@ Implement a staged security architecture that enables autonomous agents to use S
|
||||
- Phase C: Operations
|
||||
Add posture tooling, rotation workflows, recovery hardening, and conflict-safe sync semantics.
|
||||
|
||||
## Implementation Snapshot (2026-03-01)
|
||||
|
||||
- Implemented: 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
|
||||
- In Progress: 2 (continuing enforcement-depth hardening and broad validation across all operation paths)
|
||||
- Tracking source of truth: `docs/security_readiness_checklist.md` (Agent Autonomy Control Track section)
|
||||
|
||||
## 1. Native Non-Interactive Auth Brokers
|
||||
- Problem:
|
||||
Agent auth depends heavily on env vars and manual password flow.
|
||||
|
||||
@@ -24,6 +24,19 @@ Backups are published as parameterised replaceable events:
|
||||
|
||||
When restoring, SeedPass downloads the most recent manifest and applies any newer delta events.
|
||||
|
||||
## Deterministic Merge and Replay Behavior
|
||||
|
||||
Delta application uses deterministic conflict resolution so multiple devices
|
||||
converge on the same state even when updates arrive out of order:
|
||||
|
||||
- newer `modified_ts` wins
|
||||
- equal timestamp conflicts use deterministic hash tie-breaks
|
||||
- deletions are tracked via tombstones in `_sync_meta.tombstones`
|
||||
- stale payload replay does not override newer state
|
||||
|
||||
For the detailed merge contract, see
|
||||
[sync_conflict_contract.md](sync_conflict_contract.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No events found:** ensure the relays are reachable and that the correct fingerprint is selected.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Policy As Code
|
||||
|
||||
SeedPass agent policy is stored as versioned JSON (`version: 1`) at:
|
||||
|
||||
- `~/.seedpass/agent_policy.json`
|
||||
|
||||
The policy file is treated as code: linted, reviewed, and then applied.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Create or edit a candidate policy file in source control.
|
||||
2. Lint and normalize it:
|
||||
|
||||
```bash
|
||||
seedpass agent policy-lint --file ./policy/agent_policy.json
|
||||
```
|
||||
|
||||
3. Review differences against active policy:
|
||||
|
||||
```bash
|
||||
seedpass agent policy-review --file ./policy/agent_policy.json
|
||||
```
|
||||
|
||||
4. Apply with risk gate:
|
||||
|
||||
```bash
|
||||
seedpass agent policy-apply --file ./policy/agent_policy.json
|
||||
```
|
||||
|
||||
If review finds high-risk posture changes (for example `default_effect=allow`,
|
||||
unsafe output defaults, or broad export enablement), apply is blocked unless
|
||||
`--allow-risky` is supplied.
|
||||
|
||||
## Deterministic Metadata
|
||||
|
||||
`policy-lint`, `policy-review`, and `policy-apply` emit:
|
||||
|
||||
- `policy_stamp`: export-relevant deterministic stamp.
|
||||
- `policy_hash`: full normalized-policy SHA-256 hash.
|
||||
|
||||
These values make policy changes auditable in CI and change reviews.
|
||||
|
||||
Policy-filtered export packages (`mode: policy_filtered`) include deterministic
|
||||
manifest integrity metadata:
|
||||
|
||||
- `included_entry_indexes`
|
||||
- `included_entry_count`
|
||||
- `entries_sha256` (canonical hash of filtered entries)
|
||||
|
||||
`agent export-manifest-verify` validates these fields against current policy and
|
||||
detects tampering (for example changed entry kinds or modified entry payloads).
|
||||
|
||||
## Remediation Bundles
|
||||
|
||||
You can generate an actionable remediation bundle from current posture findings:
|
||||
|
||||
```bash
|
||||
seedpass agent posture-remediate
|
||||
```
|
||||
|
||||
For profile runtime checks (quick unlock, KDF strength, partition unlock drift),
|
||||
run with broker auth and fingerprint:
|
||||
|
||||
```bash
|
||||
seedpass --fingerprint <fp> agent posture-remediate \
|
||||
--check-runtime-config --auth-broker keyring
|
||||
```
|
||||
|
||||
## Legacy Compatibility
|
||||
|
||||
Legacy policy keys are still accepted and normalized to the rule model:
|
||||
|
||||
- `allow_kinds`
|
||||
- `deny_private_reveal`
|
||||
- `allow_export_import`
|
||||
|
||||
Normalization keeps backwards compatibility while preserving strict validation
|
||||
for versioned policy files.
|
||||
@@ -33,3 +33,24 @@ The selected `time_cost` is stored in the profile's configuration and used for s
|
||||
|
||||
- Production readiness checklist: `docs/security_readiness_checklist.md`
|
||||
- Threat model draft: `docs/threat_model.md`
|
||||
- Agent autonomy plan: `docs/agent_autonomy_security_plan.md`
|
||||
- Policy as code workflows: `docs/policy_as_code.md`
|
||||
- Sync conflict determinism contract: `docs/sync_conflict_contract.md`
|
||||
|
||||
## Agent Security Controls (Current)
|
||||
|
||||
SeedPass now includes dedicated controls for autonomous and CI usage:
|
||||
|
||||
- Non-interactive auth brokers (`env`, `keyring`, `command`, `prompt`).
|
||||
- Fine-grained policy controls with lint/review/apply flow.
|
||||
- Scoped, revocable tokens and agent identity binding.
|
||||
- One-time/N-use secret leases.
|
||||
- Policy-enforced redaction defaults in agent outputs.
|
||||
- Approval gates for high-risk operations.
|
||||
- Secret-class isolation for high-risk material.
|
||||
- Safer job automation primitives with signed templates and policy stamps.
|
||||
- Deterministic, policy-filtered export controls with manifest verification.
|
||||
- Posture checks and remediation generation for drift detection.
|
||||
- Chained audit log integrity verification.
|
||||
|
||||
Use `seedpass capabilities --format json` for machine-readable feature discovery.
|
||||
@@ -56,6 +56,32 @@ Status legend: `Not Started`, `In Progress`, `Blocked`, `Done`
|
||||
- Independent audit completed.
|
||||
- Critical findings resolved before broad production use.
|
||||
|
||||
## Agent Autonomy Control Track (15-Item Plan)
|
||||
|
||||
Status legend: `Not Started`, `In Progress`, `Implemented`, `Validated`
|
||||
|
||||
| # | Control | Status | Evidence |
|
||||
|---|---------|--------|----------|
|
||||
| 1 | Native non-interactive auth brokers | Implemented | `src/seedpass/core/auth_broker.py`, `src/seedpass/cli/agent.py` (`--auth-broker` flows) |
|
||||
| 2 | Fine-grained RBAC per profile | In Progress | `agent_policy.json` rule model (`operations`, `kinds`, `label_regex`, `path_regex`, `fields`) in `src/seedpass/cli/agent.py` |
|
||||
| 3 | Short-lived scoped tokens | Implemented | `agent token-issue/list/revoke`, token TTL/use constraints in `src/seedpass/cli/agent.py` |
|
||||
| 4 | One-time secret leases | Implemented | `agent get --lease-only`, `agent lease-consume/list/revoke` |
|
||||
| 5 | Policy-enforced redaction | Implemented | `safe_output_default` and redaction fields in policy + agent output paths |
|
||||
| 6 | Auditable event log | Implemented | `~/.seedpass/agent_audit.log` chained records, `agent audit-verify` |
|
||||
| 7 | Approval gates | Implemented | `agent approval-issue/list/revoke`; enforced for export/reveal high-risk actions |
|
||||
| 8 | Secret-class isolation | Implemented | `agent high-risk-factor-set/unlock/status/lock`, partition migration/store modules |
|
||||
| 9 | Agent identities | Implemented | `agent identity-create/list/revoke`; token identity binding |
|
||||
| 10 | Safer automation primitives | Implemented | `agent job-run`, job profiles, signed cron/systemd/API templates |
|
||||
| 11 | Recovery hardening | Implemented | `agent recovery-split/recover/drill/drill-list` |
|
||||
| 12 | Conflict-safe sync primitives | Implemented | deterministic merge + tombstones in `src/seedpass/core/sync_conflict.py` |
|
||||
| 13 | Policy as code | Implemented | `agent policy-lint/review/apply` + `docs/policy_as_code.md` |
|
||||
| 14 | Deterministic export controls | Implemented | policy-filtered export manifests + `agent export-check/export-manifest-verify` |
|
||||
| 15 | Security posture tooling | Implemented | `agent posture-check/posture-remediate` with runtime config checks |
|
||||
|
||||
Notes:
|
||||
- `Implemented` indicates feature availability in CLI/API paths and tests.
|
||||
- `Validated` should be used after broader integration soak + external security review.
|
||||
|
||||
## How To Use
|
||||
|
||||
1. Update each row with current status and owner.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Sync Conflict Contract
|
||||
|
||||
This document defines the deterministic merge contract for index payloads handled by:
|
||||
|
||||
- `seedpass.core.encryption.EncryptionManager.decrypt_and_save_index_from_nostr(..., merge=True)`
|
||||
- `seedpass.core.sync_conflict.merge_index_payloads(...)`
|
||||
|
||||
## Goals
|
||||
|
||||
- Deterministic convergence across devices.
|
||||
- Idempotent replay of identical payloads.
|
||||
- Safe deletion semantics that prevent stale resurrection.
|
||||
|
||||
## Merge Strategy
|
||||
|
||||
Current strategy ID:
|
||||
|
||||
- `modified_ts_hash_tombstone_v2`
|
||||
|
||||
Per-entry merge rules:
|
||||
|
||||
1. Higher `modified_ts` wins.
|
||||
2. If `modified_ts` is equal, tie-break by canonical entry hash.
|
||||
3. On equal timestamp, selected fields are union-merged deterministically:
|
||||
- `tags` and `custom_fields` use canonical sorted union.
|
||||
- Optional kind-specific fields are backfilled when one side is empty.
|
||||
4. `archived` at equal timestamp uses conservative OR semantics.
|
||||
|
||||
## Tombstone Semantics
|
||||
|
||||
Deletions are represented in `_sync_meta.tombstones`:
|
||||
|
||||
```json
|
||||
{
|
||||
"_sync_meta": {
|
||||
"tombstones": {
|
||||
"<index>": {
|
||||
"deleted_ts": 1710000000,
|
||||
"entry_hash": "<optional-entry-hash>",
|
||||
"event_hash": "<deterministic-event-hash>",
|
||||
"source": "<optional-source-tag>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. Tombstone with higher `deleted_ts` wins.
|
||||
2. Equal `deleted_ts` tie-breaks by tombstone `event_hash`.
|
||||
3. Entry vs tombstone:
|
||||
- `entry.modified_ts < deleted_ts`: entry removed.
|
||||
- `entry.modified_ts > deleted_ts`: tombstone dropped.
|
||||
- equal timestamp: event hash tie-break.
|
||||
|
||||
## `_sync_meta` Contract
|
||||
|
||||
`_sync_meta` is merged deterministically and currently includes:
|
||||
|
||||
- `strategy`: merge strategy identifier.
|
||||
- `last_merge_ts`: deterministic max timestamp observed during merge.
|
||||
- `source_count`: count of tracked source tags.
|
||||
- `sources`: lexicographically sorted source tags (bounded list).
|
||||
- `tombstones`: bounded map of delete markers.
|
||||
|
||||
`last_merge_ts` must not depend on wall-clock time; it is derived from existing/incoming metadata and payload timestamps so replaying the same payload yields identical state.
|
||||
|
||||
## Replay and Ordering Guarantees
|
||||
|
||||
- Replaying the same payload must be idempotent.
|
||||
- Applying stale payloads must not override newer state.
|
||||
- Stale re-adds must not resurrect entries deleted by newer tombstones.
|
||||
- Newer re-creates can legitimately supersede older tombstones.
|
||||
|
||||
## Retention
|
||||
|
||||
- Tombstones are retained in a bounded map (`TOMBSTONE_RETENTION_CAP`) to prevent unbounded metadata growth.
|
||||
- The cap logic is deterministic.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Core invariants are covered in `src/tests/test_delta_merge.py`, including order independence, replay idempotency, stale replay handling, and tombstone convergence.
|
||||
@@ -55,6 +55,10 @@
|
||||
<li><a href="#" data-file="ARCHITECTURE.md" class="doc-link">> Architecture</a></li>
|
||||
<li><a href="#" data-file="SPEC.md" class="doc-link">> Specification</a></li>
|
||||
<li><a href="#" data-file="security.md" class="doc-link">> Security</a></li>
|
||||
<li><a href="#" data-file="security_readiness_checklist.md" class="doc-link">> Security Readiness</a></li>
|
||||
<li><a href="#" data-file="agent_autonomy_security_plan.md" class="doc-link">> Agent Security Plan</a></li>
|
||||
<li><a href="#" data-file="policy_as_code.md" class="doc-link">> Policy as Code</a></li>
|
||||
<li><a href="#" data-file="sync_conflict_contract.md" class="doc-link">> Sync Conflict Contract</a></li>
|
||||
<li><a href="#" data-file="nostr_setup.md" class="doc-link">> Nostr Setup</a></li>
|
||||
<li><a href="#" data-file="packaging.md" class="doc-link">> Packaging</a></li>
|
||||
<li><a href="#" data-file="agent_testing_roadmap.md" class="doc-link">> Testing Roadmap</a>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<ul class="nav-links">
|
||||
<li><a href="#intro" class="nav-btn">[ HOME ]</a></li>
|
||||
<li><a href="#features" class="nav-btn">[ FEATURES ]</a></li>
|
||||
<li><a href="#agents" class="nav-btn">[ FOR AGENTS ]</a></li>
|
||||
<li><a href="#architecture" class="nav-btn">[ ARCHITECTURE ]</a></li>
|
||||
<li><a href="./docs.html" class="nav-btn">[ DOCS ]</a></li>
|
||||
</ul>
|
||||
@@ -124,6 +125,53 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- For Agents Section -->
|
||||
<section id="agents" class="ascii-box mt-2">
|
||||
<h2 class="section-title"><span class="accent">#</span> For_Agents</h2>
|
||||
<p class="muted">Machine-readable discovery, non-interactive auth, scoped access, and audit-first automation.</p>
|
||||
|
||||
<div class="agent-quickstart">
|
||||
<span class="comment"># Bootstrap context before autonomous execution</span>
|
||||
<div class="cmd-prompt">
|
||||
<span class="prompt-symbol">❯</span>
|
||||
<code class="install-cmd">seedpass capabilities --format json && seedpass agent bootstrap-context</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-features agent-grid">
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<h3>Brokered Auth</h3>
|
||||
<p>Use <code>keyring</code> or <code>command</code> brokers for non-interactive unlock without plaintext env secrets.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-scale-balanced"></i>
|
||||
<h3>Policy as Code</h3>
|
||||
<p>Lint, review, and apply policy files with deterministic output and risk-gated changes.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-id-badge"></i>
|
||||
<h3>Scoped Identity & Tokens</h3>
|
||||
<p>Issue revocable TTL-bound tokens tied to agent identities, kinds, scopes, and use limits.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-mask"></i>
|
||||
<h3>Safe Output & Leases</h3>
|
||||
<p>Default redaction plus one-time or N-use secret leases for reduced exposure in logs and pipelines.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-person-circle-check"></i>
|
||||
<h3>Approval + Isolation</h3>
|
||||
<p>Step-up approvals and separate high-risk unlock factors for seed/SSH/PGP/Nostr retrieval paths.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
<h3>Audit, Posture, Recovery</h3>
|
||||
<p>Chained audit logs, posture checks/remediation, Shamir split-drills, and deterministic export validation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Architecture & How It Works Section -->
|
||||
<section id="architecture" class="ascii-box mt-2">
|
||||
<h2 class="section-title"><span class="accent">#</span> Architecture_Overview</h2>
|
||||
|
||||
@@ -458,6 +458,20 @@ a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Agent Section */
|
||||
.agent-quickstart {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
background: #0b0f13;
|
||||
padding: 16px;
|
||||
border-left: 2px solid var(--accent-secondary);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.agent-grid .feature-item code {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
/* Features Grid */
|
||||
.grid-features {
|
||||
display: grid;
|
||||
|
||||
@@ -113,3 +113,475 @@
|
||||
|
||||
## Warnings / gotchas
|
||||
- Default policy currently allows `totp` in `allow_kinds`; denial tests must provide an explicit restrictive policy fixture when validating blocked TOTP export.
|
||||
|
||||
# Memory Update — export-reason-codes-audit-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Export policy denials are clearer for automation when returned as stable reason codes (`policy_deny:*`) rather than free-text strings.
|
||||
- Recording explicit allow/deny export events in a dedicated signed audit log improves traceability without leaking secret material.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Use shared policy evaluators (`evaluate_full_export`, `evaluate_kind_export`) across CLI and API to prevent decision drift.
|
||||
- Keep denial code constants centralized in `agent_export_policy.py` so tests and clients can depend on stable semantics.
|
||||
|
||||
## Warnings / gotchas
|
||||
- If using restrictive export tests for TOTP, ensure the policy fixture excludes `totp`, since default policy allows it.
|
||||
|
||||
# Memory Update — export-check-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- A dedicated dry-run command (`agent export-check`) reduces failed export attempts and clarifies policy decisions before running sensitive operations.
|
||||
- Optional strict non-zero exit (`--strict-exit`) is useful for CI/agent workflows without forcing failures for exploratory checks.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Return deterministic JSON fields (`mode`, `allowed`, `reason`, `allow_kinds`) so agents can branch behavior without parsing free text.
|
||||
- Reuse shared policy evaluators from `agent_export_policy.py` to keep check and enforcement decisions aligned.
|
||||
|
||||
## Warnings / gotchas
|
||||
- `--mode kind` should require `--kind`; treat missing kind as input error, not policy denial.
|
||||
|
||||
# Memory Update — api-export-check-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- API parity for dry-run export checks (`/api/v1/export/check`) lets headless agents preflight export permissions without invoking export operations.
|
||||
- Returning HTTP 200 with `allowed=false` for policy denials (and 400 only for malformed input like missing kind/invalid mode) keeps control-flow predictable.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Keep API and CLI preflight aligned by reusing shared evaluators from `agent_export_policy.py`.
|
||||
- Include preflight endpoint in capability discovery output so agents can find it automatically.
|
||||
|
||||
## Warnings / gotchas
|
||||
- `mode=kind` should reject missing `kind` via explicit `missing_kind` error code to avoid ambiguous denial semantics.
|
||||
|
||||
# Memory Update — filtered-export-manifest-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Policy-filtered exports benefit from an embedded deterministic `_export_manifest` that captures policy stamp, allowed kinds, redaction fields, and included/excluded entry counts.
|
||||
- The same manifest builder should be reused by CLI and API filtered exports to avoid divergence.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Keep manifests deterministic by excluding timestamps and sorting keys/indexes.
|
||||
- Include a hash-based `policy_stamp` derived from canonical policy controls to support reproducibility and audit correlation.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Changes introducing JSON processing in API or tests require explicit `import json`; missing imports can surface as runtime failures in async route tests.
|
||||
|
||||
# Memory Update — export-manifest-verify-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Manifest verification should compare against the current normalized policy controls (not minimal export-only policy fields) to prevent false trust in stale or partial policy states.
|
||||
- A strict-exit toggle supports both CI gating and exploratory diagnostics.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Use stable error identifiers (`policy_stamp_mismatch`, `allow_kinds_mismatch`, `included_entry_indexes_mismatch`) to make verification machine-actionable.
|
||||
- Keep verification input as plaintext JSON package to avoid coupling verifier with key management/decryption paths.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Tests that build and verify manifests must ensure both generator and verifier resolve policy from the same `APP_DIR` context.
|
||||
|
||||
# Memory Update — api-manifest-verify-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- API parity for manifest verification (`/api/v1/export/manifest/verify`) enables headless agents to validate filtered export packages without shell access.
|
||||
- Returning verification results as HTTP 200 with `valid` + `errors` simplifies client control flow compared to mixed status-code semantics.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Reuse shared verification primitives from `agent_export_policy.py` across CLI and API to guarantee identical validation logic.
|
||||
- Add new verification endpoints to `capabilities` discovery output so automation can discover them without hardcoded paths.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Policy mismatch tests should mutate `agent_policy.json` after package generation to ensure `policy_stamp_mismatch` is exercised reliably.
|
||||
- Implemented approval-gate groundwork (Item 7) for agent-risk export workflows.
|
||||
- Added `seedpass.core.agent_approval` helpers for approval issue/list/revoke/consume with TTL, use limits, and revocation persisted in `~/.seedpass/agent_approvals.json` (0600 perms).
|
||||
- Added policy helper `approval_required(policy, action)` and wired policy parsing of `approvals.require_for` through export-policy loading.
|
||||
- Added agent CLI commands: `agent approval-issue`, `agent approval-list`, `agent approval-revoke` and exposed these in bootstrap-context command discovery.
|
||||
- Enforced step-up approval for full vault export when policy requires `export`:
|
||||
- CLI: `seedpass vault export --agent-profile --approval-id <id>`
|
||||
- API: `POST /api/v1/vault/export` with header `X-SeedPass-Approval-Id`.
|
||||
- Denials now return policy reasons (`policy_deny:approval_required` or consumed-approval failures) and are logged through export policy audit events.
|
||||
- Updated capabilities map to include approval commands and `X-SeedPass-Approval-Id` as a sensitive header.
|
||||
- Added tests for approval issue/list/revoke plus CLI/API full-export approval gating; targeted suites passing:
|
||||
- `src/tests/test_cli_agent_mode.py` (27 passed)
|
||||
- `src/tests/test_typer_cli.py` (43 passed)
|
||||
- `src/tests/test_api_new_endpoints.py` (41 passed)
|
||||
- Extended approval gates (Item 7 scope) beyond export:
|
||||
- CLI `vault reveal-parent-seed` now supports `--agent-profile` + `--approval-id` and enforces policy `approvals.require_for` for `reveal_parent_seed`.
|
||||
- API `POST /api/v1/vault/backup-parent-seed` now supports `X-SeedPass-Agent-Profile` + `X-SeedPass-Approval-Id` and enforces approval for `reveal_parent_seed` when configured.
|
||||
- Expanded agent private retrieval path:
|
||||
- `agent get` accepts `--approval-id`.
|
||||
- Added private-kind retrieval support for `seed`, `managed_account`, `ssh`, `pgp`, `nostr` with policy/tokens/redaction pipeline.
|
||||
- Enforced `private_key_retrieval` approval requirement for private kinds with consume-on-use approvals.
|
||||
- Updated capabilities map high-risk operations to reflect agent sensitive retrieval and backup-parent-seed API path.
|
||||
- New/updated tests passed:
|
||||
- `src/tests/test_cli_agent_mode.py` (28 passed)
|
||||
- `src/tests/test_typer_cli.py` (45 passed)
|
||||
- `src/tests/test_api_new_endpoints.py` (43 passed)
|
||||
- Implemented Item 4 one-time secret leases as first-class agent capability.
|
||||
- Added `src/seedpass/core/agent_secret_lease.py` with versioned lease store, TTL/use-limit enforcement, revocation, and cross-process locking via `utils.file_lock.exclusive_lock`.
|
||||
- Added agent lease commands:
|
||||
- `agent get --lease-only --ttl <sec> --lease-uses <n>` issues a resource-bound lease without returning secret.
|
||||
- `agent lease-consume <lease_id>` consumes one use and returns secret (policy + redaction applied).
|
||||
- `agent lease-list`, `agent lease-revoke` for lease lifecycle management.
|
||||
- Lease records are tied to fingerprint/resource and optionally token id metadata; no plaintext secret material is persisted in lease store.
|
||||
- Updated `agent bootstrap-context` command registry to include lease operations.
|
||||
- Updated capabilities map to advertise lease support.
|
||||
- Added regression tests:
|
||||
- Lease issue/consume/exhaust flow in `src/tests/test_cli_agent_mode.py`.
|
||||
- Capabilities JSON includes `security_features.leases` in `src/tests/test_typer_cli.py`.
|
||||
- Verified passing suites:
|
||||
- `src/tests/test_cli_agent_mode.py` (29 passed)
|
||||
- `src/tests/test_typer_cli.py` (45 passed)
|
||||
- `src/tests/test_api_new_endpoints.py` (43 passed)
|
||||
- Implemented Item 9 agent identities with lifecycle management and token binding.
|
||||
- Added `src/seedpass/core/agent_identity.py` (versioned identity store) with create/list/get/revoke/ensure/active helpers.
|
||||
- Added new agent CLI commands:
|
||||
- `agent identity-create --id ... --owner ... --policy-binding ... --rotation-days ...`
|
||||
- `agent identity-list [--show-revoked]`
|
||||
- `agent identity-revoke <id>`
|
||||
- Bound new token issuance to identities:
|
||||
- `agent token-issue` now supports `--identity-id` (default `default-agent`) and auto-creates default identity as needed.
|
||||
- Token records include `identity_id`.
|
||||
- Enforced identity state at token use time:
|
||||
- `_validate_token` now denies with `token_identity_revoked` when token identity is revoked/missing.
|
||||
- Updated bootstrap context and capabilities to advertise identity features/commands.
|
||||
- Added posture findings:
|
||||
- `tokens_missing_identity`
|
||||
- `tokens_for_revoked_identity`
|
||||
- Added/updated tests in `src/tests/test_cli_agent_mode.py` and `src/tests/test_typer_cli.py`:
|
||||
- identity create/list/revoke
|
||||
- token denied after identity revocation
|
||||
- bootstrap/capabilities include identity command surface
|
||||
- Verified test suites:
|
||||
- `src/tests/test_cli_agent_mode.py` (31 passed)
|
||||
- `src/tests/test_typer_cli.py` (45 passed)
|
||||
- `src/tests/test_api_new_endpoints.py` (43 passed)
|
||||
- Started Item 8 secret-class isolation with a separate high-risk unlock factor/session for agent workflows.
|
||||
- Added `src/seedpass/core/agent_secret_isolation.py`:
|
||||
- stores hashed high-risk factor (`agent_high_risk_factor.hash`),
|
||||
- manages per-fingerprint high-risk unlock sessions with TTL (`agent_high_risk_unlock.json`),
|
||||
- uses `exclusive_lock` for concurrency-safe session updates.
|
||||
- Extended agent policy schema normalization to include `secret_isolation`:
|
||||
- `enabled` (bool),
|
||||
- `high_risk_kinds` (subset of private kinds),
|
||||
- `unlock_ttl_sec` (>=1).
|
||||
- Added new agent CLI commands:
|
||||
- `agent high-risk-factor-set`
|
||||
- `agent high-risk-unlock`
|
||||
- `agent high-risk-status`
|
||||
- `agent high-risk-lock`
|
||||
- Enforced secret isolation in access paths:
|
||||
- `agent get` denies private/high-risk retrieval with `policy_deny:high_risk_locked` unless unlocked,
|
||||
- `agent lease-consume` applies same high-risk lock check for private kinds.
|
||||
- Updated bootstrap context and capabilities to advertise secret isolation command surface/features.
|
||||
- Added/updated tests:
|
||||
- integrated private-kind lock/unlock flow test in `src/tests/test_cli_agent_mode.py`,
|
||||
- bootstrap context assertions include secret isolation commands,
|
||||
- capabilities JSON assertions include `security_features.secret_isolation`.
|
||||
- Verified suites passing after changes:
|
||||
- `src/tests/test_cli_agent_mode.py` (32 passed)
|
||||
- `src/tests/test_typer_cli.py` (45 passed)
|
||||
- `src/tests/test_api_new_endpoints.py` (43 passed)
|
||||
- Continued Item 8 by extending secret-class isolation beyond agent-only retrieval into broader CLI/API high-risk flows.
|
||||
- Non-agent CLI enforcement updates:
|
||||
- `vault reveal-parent-seed` now checks high-risk isolation lock when policy isolation is enabled for `seed` and a high-risk factor is configured.
|
||||
- `vault export` now checks high-risk isolation lock under the same conditions before full export.
|
||||
- API high-risk session endpoints added in `src/seedpass/api.py`:
|
||||
- `GET /api/v1/high-risk/status`
|
||||
- `POST /api/v1/high-risk/unlock` (requires token + password + `X-SeedPass-High-Risk-Factor`)
|
||||
- `POST /api/v1/high-risk/lock`
|
||||
- API high-risk enforcement updates:
|
||||
- `/api/v1/vault/export` enforces high-risk unlocked session when export mode/policy includes isolated high-risk kinds.
|
||||
- `/api/v1/vault/backup-parent-seed` enforces high-risk unlocked session for `seed` when isolation applies.
|
||||
- Extended export policy parser (`agent_export_policy.load_export_policy`) to support `secret_isolation` fields so API-side isolation decisions are policy-driven.
|
||||
- Capabilities map now advertises new high-risk API endpoints and `X-SeedPass-High-Risk-Factor` sensitive header.
|
||||
- Added regression tests:
|
||||
- CLI: reveal-parent-seed blocked when high-risk locked.
|
||||
- API: high-risk unlock/status/lock endpoints, export blocked when high-risk locked, backup-parent-seed blocked when high-risk locked.
|
||||
- Verified passing targeted suites:
|
||||
- `src/tests/test_typer_cli.py` (46 passed)
|
||||
- `src/tests/test_api_new_endpoints.py` (46 passed)
|
||||
- `src/tests/test_cli_agent_mode.py` (32 passed)
|
||||
- Advanced Item 8 toward vault/service-layer coverage by adding explicit partition metadata + retrieval gating.
|
||||
- Added secret-class partition metadata defaults and helpers to `ConfigManager`:
|
||||
- default `secret_class_partitions` with `standard` and `high_risk` classes,
|
||||
- helpers: `get_secret_class_partitions`, `set_secret_class_partitions`, `set_partition_unlock_state`, `is_partition_unlocked`.
|
||||
- API service-layer hardening in `src/seedpass/api.py`:
|
||||
- `/api/v1/high-risk/unlock` and `/api/v1/high-risk/lock` now update config partition unlock state (best-effort),
|
||||
- `/api/v1/entry/{id}` now enforces high-risk isolation for private kinds (`seed/ssh/pgp/nostr/managed_account`) using policy + session state.
|
||||
- Policy parsing hardening in `agent_export_policy`:
|
||||
- `load_export_policy` now parses `secret_isolation` block (enabled/high_risk_kinds/unlock_ttl_sec) for API-side decisions,
|
||||
- policy stamp includes secret isolation fields.
|
||||
- Added API regression test for private entry retrieval isolation:
|
||||
- blocked while high-risk locked,
|
||||
- allowed after `/api/v1/high-risk/unlock`.
|
||||
- Re-verified key targeted suites after changes:
|
||||
- `src/tests/test_api_new_endpoints.py` (47 passed)
|
||||
- `src/tests/test_typer_cli.py` + `src/tests/test_cli_agent_mode.py` (78 passed combined)
|
||||
- Continued Item 8 with explicit class-partition metadata and deeper service-layer enforcement.
|
||||
- Added partition metadata defaults + helpers in `ConfigManager`:
|
||||
- `secret_class_partitions` default includes `standard` and `high_risk` classes,
|
||||
- helper methods for reading/updating partition metadata and unlock state.
|
||||
- API high-risk unlock/lock endpoints now persist partition unlock state (best-effort) via config manager.
|
||||
- Added retrieval-layer isolation enforcement in API:
|
||||
- `/api/v1/entry/{id}` checks entry kind and denies with `policy_deny:high_risk_locked` for private kinds when high-risk session is locked.
|
||||
- Added regression test for API retrieval gating:
|
||||
- private entry blocked while locked,
|
||||
- allowed after high-risk unlock.
|
||||
- Re-ran targeted suites successfully:
|
||||
- `src/tests/test_api_new_endpoints.py` + `src/tests/test_typer_cli.py` + `src/tests/test_cli_agent_mode.py` (125 passed combined)
|
||||
- `src/tests/test_core_api_services.py` + `src/tests/test_password_change.py` + `src/tests/test_kdf_strength_slider.py` (55 passed combined)
|
||||
- Strengthened Item 8 isolation model from hash-check to cryptographic partition-key envelope.
|
||||
- Updated `agent_secret_isolation`:
|
||||
- high-risk factor setup now generates a random partition key and stores it wrapped in `agent_high_risk_partition.key.enc.json` using factor-derived key (PBKDF2-SHA256 + Fernet envelope).
|
||||
- `verify_high_risk_factor` now validates by decrypting envelope (legacy hash fallback retained if envelope absent).
|
||||
- Added `unwrap_high_risk_partition_key`, `partition_key_tag_for_factor`, and session storage of `partition_key_tag` on unlock grants.
|
||||
- Updated unlock flows to require envelope unwrap success before granting session:
|
||||
- CLI `agent high-risk-unlock` computes/stores partition key tag.
|
||||
- API `/api/v1/high-risk/unlock` computes/stores partition key tag.
|
||||
- Existing policy/session isolation enforcement remains in place; now backed by cryptographic factor proof rather than plain hash compare.
|
||||
- Verified suites after envelope change:
|
||||
- `src/tests/test_cli_agent_mode.py` + `src/tests/test_api_new_endpoints.py` (79 passed combined)
|
||||
- `src/tests/test_typer_cli.py` (46 passed)
|
||||
- `src/tests/test_core_api_services.py` + `src/tests/test_password_change.py` + `src/tests/test_kdf_strength_slider.py` (55 passed combined)
|
||||
|
||||
# Memory Update — secret-isolation-partition-migration-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Added an encrypted high-risk partition store (`src/seedpass/core/high_risk_partition_store.py`) that migrates private/high-risk entries out of the primary index into `seedpass_high_risk_entries.json.enc` and leaves deterministic stubs in index.
|
||||
- High-risk factor handling now supports a wrapped partition key envelope (`agent_high_risk_partition.key.enc.json`) so unlock sessions can carry a partition key tag for partition reads.
|
||||
- `agent lease-consume` needed explicit partition hydration for private kinds; without this, partitioned entries could fail downstream secret resolution.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Keep partition access behind existing high-risk unlock checks and session TTLs; use the unlock session as the only source of partition key tag context.
|
||||
- Add command-level migration (`agent high-risk-partition-migrate`) before enforcing partition-only retrieval to keep rollout safe for existing profiles.
|
||||
- Regression tests for partition flows are most reliable in `src/tests/test_cli_agent_mode.py` by monkeypatching hydration and migration seams.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Any path that reads private entries by index must hydrate `partition=high_risk` stubs before attempting kind-specific secret derivation.
|
||||
- API/CLI behavior can diverge if one retrieval path is updated without the other; keep partition rules centralized where possible.
|
||||
- Added API parity for partition hydration in `GET /api/v1/entry/{id}` so `partition=high_risk` stubs are resolved via unlock-session key tag before response.
|
||||
- Added API regression `test_get_entry_hydrates_partitioned_high_risk_entry` to lock this behavior.
|
||||
|
||||
# Memory Update — automation-primitives-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Added `agent job-run` as a schedule-safe wrapper over `agent get` with safe broker defaults (`keyring`/`command`) and explicit denial for `env` broker unless `--allow-env-broker` is passed.
|
||||
- Added `agent job-template` to generate deterministic cron/systemd snippets that avoid plaintext password env requirements and encode brokered auth usage.
|
||||
- Tightened token validation so legacy tokens without `identity_id` are denied at use time with `token_identity_missing`.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Reusing `ctx.invoke(agent_get, ...)` keeps policy, redaction, approval, leases, and auditing semantics consistent across interactive and automated retrieval paths.
|
||||
- Expose automation controls in both bootstrap-context command maps and capabilities output to keep discovery parity for autonomous agents.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Existing legacy tokens with empty `identity_id` now fail; rotate/reissue with `agent token-issue --identity-id ...` before enabling strict automation workflows.
|
||||
|
||||
# Memory Update — automation-job-profiles-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Added persistent automation job profiles in `src/seedpass/core/agent_job.py` with create/list/get/revoke lifecycle and 0600 on-disk storage.
|
||||
- Added agent CLI commands: `job-profile-create`, `job-profile-list`, `job-profile-run`, and `job-profile-revoke` to support reusable scheduled workflows with policy-binding metadata.
|
||||
- `job-profile-run` now executes through `agent job-run`, preserving existing policy/redaction/approval semantics while enforcing fingerprint binding.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- For safer automation rollout, keep reusable job metadata separate from token/secret material and route execution through existing guarded retrieval commands.
|
||||
- Include automation profile capabilities in both `bootstrap-context` and `capabilities` output so agents can discover the full control surface deterministically.
|
||||
|
||||
## Warnings / gotchas
|
||||
- `job-profile-run` enforces fingerprint match; invoking with a different `--fingerprint` intentionally denies.
|
||||
- Command-broker job profiles must include `--broker-command` at creation time.
|
||||
- Item 10 hardening continued with job profile policy/host guardrails.
|
||||
- `agent job-profile-create` now stores `policy_stamp` (from current normalized policy) and `host_binding` (default current hostname).
|
||||
- `agent job-profile-run` now denies by default on:
|
||||
- `job_profile_policy_mismatch` (active policy stamp drift)
|
||||
- `job_profile_host_mismatch` (host binding mismatch)
|
||||
with explicit override flags `--allow-policy-drift` and `--allow-host-mismatch`.
|
||||
- Added `agent job-profile-check` command for profile posture checks (unsafe broker, policy drift, host mismatch, staleness) with optional strict exit.
|
||||
- Updated capabilities/bootstrapped command maps to include `job-profile-check` and automation guardrail metadata.
|
||||
- Added regression tests for policy mismatch, host mismatch, and profile check drift findings.
|
||||
- Added API parity for automation job profiles (Item 10) in `src/seedpass/api.py`:
|
||||
- `GET/POST /api/v1/agent/job-profiles`
|
||||
- `DELETE /api/v1/agent/job-profiles/{job_id}`
|
||||
- `POST /api/v1/agent/job-profiles/{job_id}/run`
|
||||
- `GET /api/v1/agent/job-profiles/check`
|
||||
- API run endpoint enforces profile fingerprint binding, policy-stamp drift checks, and host-binding checks with explicit override flags, and issues secret leases for matched resources.
|
||||
- Capabilities discovery now includes new API job-profile endpoints.
|
||||
- Added API regression tests for CRUD and run behavior (including policy drift denial + override).
|
||||
- Added API template rendering endpoint for job profiles: `GET /api/v1/agent/job-profiles/{job_id}/template` with deterministic `cron` and `systemd` output modes.
|
||||
- Endpoint supports profile/default/provided schedule resolution and returns command + rendered template payload for orchestration tooling.
|
||||
- Added API regression coverage for cron and systemd template outputs.
|
||||
- Updated capabilities API discovery list to include the new template endpoint.
|
||||
- Added API template authoring + integrity workflows for automation jobs:
|
||||
- `POST /api/v1/agent/job-profiles/{job_id}/template` (richer template input)
|
||||
- `POST /api/v1/agent/job-profiles/{job_id}/template/verify` (manifest verification)
|
||||
- Added signed template manifests (HMAC-SHA256) including template hash, policy stamp, host binding, and job profile id.
|
||||
- Added deterministic template helpers shared by GET/POST template endpoints.
|
||||
- Updated capabilities metadata/discovery with API template + verify endpoints and automation manifest-signing capability.
|
||||
|
||||
# Memory Update — recovery-hardening-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Added deterministic Shamir split/recover primitives in `src/seedpass/core/agent_recovery.py` (`split_secret`, `recover_secret`) with share token checksums.
|
||||
- Added signed backup verification drill logging (`record_recovery_drill`, `list_recovery_drills`) with chained HMAC signatures.
|
||||
- Exposed recovery workflows in agent CLI:
|
||||
- `agent recovery-split`
|
||||
- `agent recovery-recover`
|
||||
- `agent recovery-drill`
|
||||
- `agent recovery-drill-list`
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Recovery commands should default to non-revealing output and require explicit `--reveal` for plaintext recovery output.
|
||||
- Drill reporting should be append-only and signed so verification history remains tamper-evident.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Current split derivation is deterministic by design for reproducibility; rotating labels should be treated as distinct recovery sets.
|
||||
- Added Item 11 API parity for recovery workflows:
|
||||
- `POST /api/v1/agent/recovery/split`
|
||||
- `POST /api/v1/agent/recovery/recover`
|
||||
- `POST /api/v1/agent/recovery/drill`
|
||||
- `GET /api/v1/agent/recovery/drills`
|
||||
- `POST /api/v1/agent/recovery/drills/verify`
|
||||
- Added drill-chain verification primitive in `agent_recovery.verify_recovery_drills` to detect tampering via chained HMAC signatures.
|
||||
- Updated capabilities discovery to include new recovery API endpoints.
|
||||
- Added API tests for split/recover roundtrip, drill list/verify, and tamper detection.
|
||||
|
||||
# Memory Update — conflict-safe-sync-v1 — 2026-03-01
|
||||
|
||||
## Key findings
|
||||
- Introduced deterministic merge primitive `merge_index_payloads` in `src/seedpass/core/sync_conflict.py`.
|
||||
- Conflict resolver now uses order-independent comparison:
|
||||
1) higher `modified_ts`
|
||||
2) on tie, lexicographically larger canonical entry hash.
|
||||
- Integrated merge primitive into Nostr merge path (`EncryptionManager.decrypt_and_save_index_from_nostr(..., merge=True)`).
|
||||
- Added encrypted sync metadata `_sync_meta` into index payload to track merge strategy and source tags.
|
||||
|
||||
## Patterns / reusable knowledge
|
||||
- Keep merge logic centralized and deterministic to avoid divergent state from different delta arrival orders.
|
||||
- Use payload-derived source tags + bounded source history in encrypted index metadata for debugging merge provenance without plaintext side channels.
|
||||
|
||||
## Warnings / gotchas
|
||||
- Existing tests that implicitly relied on "incoming wins on equal timestamp" need explicit timestamp separation if validating LWW semantics.
|
||||
- Extended Item 12 conflict-safe sync with deterministic field-level merge rules for equal-timestamp conflicts in `sync_conflict.merge_index_payloads`.
|
||||
- Equal-ts conflicts now perform deterministic winner selection by hash plus selective field union (`username/url/notes/...`) and conservative archive OR behavior.
|
||||
- Added order-independence regression tests covering both whole-entry tie resolution and field-level union behavior.
|
||||
- Integrated resolver in `EncryptionManager.decrypt_and_save_index_from_nostr(..., merge=True)` with source-tagged encrypted sync metadata.
|
||||
## 2026-03-01 - Sync conflict v2 tombstones
|
||||
- Added deterministic sync strategy `modified_ts_hash_tombstone_v2` in `src/seedpass/core/sync_conflict.py`.
|
||||
- Added `_sync_meta.tombstones` merge semantics with timestamp-first then event-hash tie-break, and retention cap.
|
||||
- Added kind-aware equal-timestamp unions (including deterministic `tags`/`custom_fields` union).
|
||||
- `EntryManager.delete_entry` now emits local tombstones before removing entries so deletions can propagate across merge-based sync.
|
||||
- Regression coverage added in `src/tests/test_delta_merge.py` for order-independence, kind-aware unions, and tombstone convergence.
|
||||
- Validation run: `src/tests/test_delta_merge.py` (7 passed), plus `src/tests/test_profile_management.py src/tests/test_api.py src/tests/test_api_new_endpoints.py` (83 passed).
|
||||
## 2026-03-01 - CLI discoverability refresh for agents/users
|
||||
- Updated root CLI help in `src/seedpass/cli/__init__.py` to direct users/agents to `seedpass capabilities` and subgroup `--help`.
|
||||
- Updated vault unlock success tip in `src/seedpass/cli/vault.py` to explicitly mention `seedpass capabilities` after unlock/login.
|
||||
- Expanded `src/seedpass/cli/capabilities.py` map with:
|
||||
- `security_features.sync` (deterministic conflict strategy `modified_ts_hash_tombstone_v2` + tombstones metadata)
|
||||
- `export_controls` commands and defaults
|
||||
- `posture` command/check descriptions
|
||||
- richer approval coverage and help hints (including post-login guidance)
|
||||
- Updated agent discoverability surfaces in `src/seedpass/cli/agent.py`:
|
||||
- richer group help text
|
||||
- `agent init` now returns `next_steps` including capabilities/bootstrap discovery
|
||||
- `agent bootstrap-context` now advertises export/posture command groups
|
||||
- Validation: `src/tests/test_typer_cli.py` + `src/tests/test_cli_agent_mode.py` passed (`92 passed`).
|
||||
## 2026-03-01 - Sync replay/idempotency hardening
|
||||
- Hardened `src/seedpass/core/sync_conflict.py` to remove wall-clock dependence from merge metadata:
|
||||
- `last_merge_ts` now derived deterministically from existing/incoming metadata and max observed entry/tombstone timestamps.
|
||||
- deleted-entry fallback tombstone timestamp now derived from merge state, not `time.time()`.
|
||||
- Added integer normalization helper for metadata parsing (`_safe_int`) and centralized max timestamp helpers.
|
||||
- Added replay-focused tests in `src/tests/test_delta_merge.py`:
|
||||
- idempotent replay of same payload (`once == twice`)
|
||||
- stale payload cannot override newer state
|
||||
- stale re-add cannot resurrect tombstoned entry
|
||||
- newer recreate supersedes tombstone and clears it
|
||||
- Validation: `src/tests/test_delta_merge.py` (11 passed) + broader regression set (`test_profile_management.py`, `test_api.py`, `test_api_new_endpoints.py`) (83 passed).
|
||||
- Added vault-boundary idempotency regression in `src/tests/test_delta_merge.py::test_merge_replay_idempotent_through_vault_boundary` by replaying same merged encrypted payload twice after baseline sync.
|
||||
## 2026-03-01 - Item 12 docs/contract pass
|
||||
- Added `docs/sync_conflict_contract.md` documenting deterministic merge strategy `modified_ts_hash_tombstone_v2`, `_sync_meta` schema, tombstone semantics, replay guarantees, and retention behavior.
|
||||
- Linked contract doc from `docs/SPEC.md`, `docs/nostr_setup.md`, and `docs/README.md`.
|
||||
- Added `test_merge_last_merge_ts_order_independent` in `src/tests/test_delta_merge.py` to lock deterministic metadata ordering behavior.
|
||||
- Validation: `src/tests/test_delta_merge.py` (13 passed) and targeted CLI discoverability checks (2 passed).
|
||||
## 2026-03-01 - Item 13 policy-as-code workflow primitives
|
||||
- Extended agent policy CLI in `src/seedpass/cli/agent.py`:
|
||||
- `agent policy-lint --file <path> [--format json|text]` with deterministic `policy_stamp` and full `policy_hash`.
|
||||
- `agent policy-review --file <path>` with deterministic unified diff, structured `diff_summary`, and risky finding surfacing.
|
||||
- `agent policy-apply --file <path>` with `--dry-run` and risk gate (`--allow-risky`) plus audit event `agent_policy_applied`.
|
||||
- Added helper functions for file-based policy normalization, full-hash computation, and rule-level change summary.
|
||||
- Updated discovery surfaces:
|
||||
- capabilities policy commands now include review/apply and policy-as-code support flags.
|
||||
- bootstrap context now includes `agent policy-review` and `agent policy-apply`.
|
||||
- Added tests:
|
||||
- `test_agent_policy_lint_file`
|
||||
- `test_agent_policy_review_and_apply_risk_gate`
|
||||
- bootstrap context expectations for new policy commands
|
||||
- capabilities JSON assertion for policy change-review support.
|
||||
- Added docs: `docs/policy_as_code.md` and linked from `docs/README.md`.
|
||||
- Validation: `src/tests/test_cli_agent_mode.py` + `src/tests/test_typer_cli.py` passed (94 passed).
|
||||
## 2026-03-01 - Item 14 deterministic export controls hardening
|
||||
- Hardened `src/seedpass/core/agent_export_policy.py` policy-filtered manifest verification:
|
||||
- manifest version advanced to v2 (with backward-compat support for v1)
|
||||
- deterministic index sorting helper for mixed/non-numeric indexes
|
||||
- added `entries_sha256` integrity field (canonical hash of filtered entries)
|
||||
- added checks for included count, schema version consistency, redacted fields consistency, entry-kind allowlist conformance, and redaction sentinel conformance
|
||||
- robust integer parsing to avoid verifier crashes on malformed manifests
|
||||
- Added tamper-detection tests:
|
||||
- CLI: `test_agent_export_manifest_verify_detects_tampered_entries`
|
||||
- API: `test_export_manifest_verify_endpoint_detects_tamper`
|
||||
- Updated capabilities map to surface manifest entry hash verification support.
|
||||
- Updated docs (`docs/policy_as_code.md`) to document deterministic manifest integrity fields and tamper verification.
|
||||
- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py src/tests/test_api_new_endpoints.py` => 152 passed.
|
||||
## 2026-03-01 - Item 15 posture tooling expansion
|
||||
- Expanded `agent posture-check` findings in `src/seedpass/cli/agent.py` with additional controls:
|
||||
- `approvals_missing_required_actions` (export/reveal_parent_seed/private_key_retrieval gate completeness)
|
||||
- `high_risk_unlock_ttl_too_long` (>1800s)
|
||||
- `over_permissive_read_rule` (broad secret-read allow rule detection)
|
||||
- `private_read_without_approval_gate` (private-kind reads without private key approval gate)
|
||||
- `identity_rotation_window_too_long` (rotation_days > 90)
|
||||
- `token_rotation_overdue` (active token age exceeds bound identity rotation_days)
|
||||
- Updated capabilities posture checklist in `src/seedpass/cli/capabilities.py` to reflect new checks.
|
||||
- Added posture regression tests in `src/tests/test_cli_agent_mode.py`:
|
||||
- `test_agent_posture_check_flags_policy_gate_and_rule_issues`
|
||||
- `test_agent_posture_check_flags_token_rotation_overdue`
|
||||
- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py` (97 passed) and `src/tests/test_api_new_endpoints.py` (57 passed).
|
||||
## 2026-03-01 - Item 15 runtime config posture checks
|
||||
- Extended `agent posture-check` in `src/seedpass/cli/agent.py` with opt-in runtime config audit:
|
||||
- new flag `--check-runtime-config` with broker options (`--auth-broker`, `--password-env`, `--broker-service`, `--broker-account`, `--broker-command`)
|
||||
- requires `--fingerprint` when runtime check is requested
|
||||
- runtime summary fields: `runtime_config_status`, `runtime_config_error`, `runtime_finding_count`
|
||||
- Added runtime findings:
|
||||
- `quick_unlock_enabled`
|
||||
- `weak_kdf_iterations`
|
||||
- `high_risk_partition_persistently_unlocked`
|
||||
- Added additional posture checks in this pass:
|
||||
- `approvals_missing_required_actions`
|
||||
- `high_risk_unlock_ttl_too_long`
|
||||
- `over_permissive_read_rule`
|
||||
- `private_read_without_approval_gate`
|
||||
- `identity_rotation_window_too_long`
|
||||
- `token_rotation_overdue`
|
||||
- Updated capabilities posture check list to include runtime config drift checks.
|
||||
- Added/updated tests in `src/tests/test_cli_agent_mode.py` for new posture findings and runtime-check behavior.
|
||||
- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py src/tests/test_api_new_endpoints.py` => 156 passed.
|
||||
## 2026-03-01 - Item 15 remediation bundle command
|
||||
- Added `agent posture-remediate` in `src/seedpass/cli/agent.py` to convert current posture findings into deterministic actionable remediation steps.
|
||||
- Command supports `--check-runtime-config` with broker auth options and mirrors runtime drift checks used by posture-check.
|
||||
- Added remediation mapping for key findings (policy gates, export controls, safe output, isolation, token hygiene, rotation, runtime config drift).
|
||||
- Updated discovery:
|
||||
- bootstrap context posture commands now include `agent posture-remediate`
|
||||
- capabilities posture command list includes remediation command
|
||||
- text capabilities summary mentions remediation bundles
|
||||
- Added tests:
|
||||
- `test_agent_posture_remediate_emits_actions`
|
||||
- `test_agent_posture_remediate_runtime_requires_fingerprint`
|
||||
- bootstrap context posture command assertion
|
||||
- capabilities JSON posture command assertion
|
||||
- Validation: `src/tests/test_cli_agent_mode.py src/tests/test_typer_cli.py src/tests/test_api_new_endpoints.py` => 158 passed.
|
||||
## 2026-03-01 - Docs and landing updates for agent/user discoverability
|
||||
- Updated root `README.md` with a dedicated **Agent and Automation Security Features** section covering implemented controls and command references (`capabilities`, policy, tokens, leases, approvals, isolation, automation jobs, recovery, export controls, posture tooling, audit logging).
|
||||
- Updated `docs/README.md` to include agent/autonomy docs and explicit discoverability commands (`seedpass --help`, group help, `capabilities`, `agent bootstrap-context`).
|
||||
- Expanded `docs/security.md` with current agent security controls and links to core plan/contract docs.
|
||||
- Updated `docs/security_readiness_checklist.md` with a new **Agent Autonomy Control Track (15-item plan)** status table and evidence pointers.
|
||||
- Updated `docs/agent_autonomy_security_plan.md` with a dated implementation snapshot (implemented vs in-progress).
|
||||
- Updated website docs index in `landing/docs.html` to include `security_readiness_checklist.md`, `agent_autonomy_security_plan.md`, `policy_as_code.md`, and `sync_conflict_contract.md`.
|
||||
- Added a new **For Agents** section to `landing/index.html` (with nav anchor) and supporting styles in `landing/style.css`, including quickstart command and agent-specific feature cards.
|
||||
+921
-8
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,11 @@ from seedpass.core.errors import SeedPassError
|
||||
from constants import GUI_BACKEND_CONFIG
|
||||
|
||||
app = typer.Typer(
|
||||
help="SeedPass command line interface",
|
||||
help=(
|
||||
"SeedPass command line interface. "
|
||||
"Run `seedpass capabilities` for a deterministic feature map "
|
||||
"and `seedpass <group> --help` for group-specific commands."
|
||||
),
|
||||
invoke_without_command=True,
|
||||
)
|
||||
|
||||
@@ -79,6 +83,8 @@ def main(
|
||||
"""SeedPass CLI entry point.
|
||||
|
||||
When called without a subcommand this launches the interactive TUI.
|
||||
Use ``seedpass capabilities --format json`` for machine-readable command
|
||||
and security feature discovery.
|
||||
"""
|
||||
ctx.obj = {
|
||||
"fingerprint": fingerprint,
|
||||
|
||||
+2697
-24
File diff suppressed because it is too large.
Load diff
@@ -34,9 +34,30 @@ def build_capabilities() -> dict[str, Any]:
|
||||
],
|
||||
},
|
||||
"api": {
|
||||
"discovery": ["/docs"],
|
||||
"discovery": [
|
||||
"/docs",
|
||||
"/api/v1/export/check",
|
||||
"/api/v1/export/manifest/verify",
|
||||
"/api/v1/agent/job-profiles",
|
||||
"/api/v1/agent/job-profiles/{job_id}/run",
|
||||
"/api/v1/agent/job-profiles/{job_id}/template",
|
||||
"/api/v1/agent/job-profiles/{job_id}/template/verify",
|
||||
"/api/v1/agent/job-profiles/check",
|
||||
"/api/v1/agent/recovery/split",
|
||||
"/api/v1/agent/recovery/recover",
|
||||
"/api/v1/agent/recovery/drill",
|
||||
"/api/v1/agent/recovery/drills",
|
||||
"/api/v1/agent/recovery/drills/verify",
|
||||
"/api/v1/high-risk/status",
|
||||
"/api/v1/high-risk/unlock",
|
||||
"/api/v1/high-risk/lock",
|
||||
],
|
||||
"auth_headers": ["Authorization: Bearer <token>"],
|
||||
"sensitive_auth_headers": ["X-SeedPass-Password"],
|
||||
"sensitive_auth_headers": [
|
||||
"X-SeedPass-Password",
|
||||
"X-SeedPass-Approval-Id",
|
||||
"X-SeedPass-High-Risk-Factor",
|
||||
],
|
||||
},
|
||||
},
|
||||
"security_features": {
|
||||
@@ -47,19 +68,136 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"api_commands": ["api start --unlock"],
|
||||
},
|
||||
"policy": {
|
||||
"commands": ["agent policy-show", "agent policy-lint", "agent policy-set"],
|
||||
"commands": [
|
||||
"agent policy-show",
|
||||
"agent policy-lint",
|
||||
"agent policy-set",
|
||||
"agent policy-review",
|
||||
"agent policy-apply",
|
||||
],
|
||||
"default_effect": "deny",
|
||||
"supports_file_lint": True,
|
||||
"supports_change_review": True,
|
||||
"supports_risk_gated_apply": True,
|
||||
},
|
||||
"sync": {
|
||||
"deterministic_conflict_resolution": "modified_ts_hash_tombstone_v2",
|
||||
"supports_tombstones": True,
|
||||
"metadata_fields": ["_sync_meta.strategy", "_sync_meta.tombstones"],
|
||||
},
|
||||
"tokens": {
|
||||
"commands": ["agent token-issue", "agent token-list", "agent token-revoke"],
|
||||
"commands": [
|
||||
"agent token-issue",
|
||||
"agent token-list",
|
||||
"agent token-revoke",
|
||||
],
|
||||
"supports_ttl": True,
|
||||
"supports_use_limits": True,
|
||||
"supports_revocation": True,
|
||||
},
|
||||
"identities": {
|
||||
"commands": [
|
||||
"agent identity-create",
|
||||
"agent identity-list",
|
||||
"agent identity-revoke",
|
||||
],
|
||||
"token_binding_required_for_new_tokens": True,
|
||||
"supports_revocation": True,
|
||||
},
|
||||
"leases": {
|
||||
"commands": [
|
||||
"agent get --lease-only",
|
||||
"agent lease-consume",
|
||||
"agent lease-list",
|
||||
"agent lease-revoke",
|
||||
],
|
||||
"supports_ttl": True,
|
||||
"supports_use_limits": True,
|
||||
"supports_one_time": True,
|
||||
},
|
||||
"automation": {
|
||||
"commands": [
|
||||
"agent job-run",
|
||||
"agent job-template",
|
||||
"agent job-profile-create",
|
||||
"agent job-profile-list",
|
||||
"agent job-profile-run",
|
||||
"agent job-profile-revoke",
|
||||
"agent job-profile-check",
|
||||
],
|
||||
"safe_default_brokers": ["keyring", "command"],
|
||||
"supports_cron_templates": True,
|
||||
"supports_systemd_templates": True,
|
||||
"supports_api_templates": True,
|
||||
"supports_persistent_job_profiles": True,
|
||||
"enforces_policy_stamp_on_run": True,
|
||||
"supports_host_binding": True,
|
||||
"template_manifest_signing": "hmac-sha256",
|
||||
},
|
||||
"recovery": {
|
||||
"commands": [
|
||||
"agent recovery-split",
|
||||
"agent recovery-recover",
|
||||
"agent recovery-drill",
|
||||
"agent recovery-drill-list",
|
||||
],
|
||||
"supports_shamir_split_recover": True,
|
||||
"supports_signed_drill_reports": True,
|
||||
},
|
||||
"secret_isolation": {
|
||||
"commands": [
|
||||
"agent high-risk-factor-set",
|
||||
"agent high-risk-unlock",
|
||||
"agent high-risk-status",
|
||||
"agent high-risk-lock",
|
||||
],
|
||||
"high_risk_classes": ["seed", "ssh", "pgp", "nostr", "managed_account"],
|
||||
"separate_unlock_factor": True,
|
||||
},
|
||||
"approvals": {
|
||||
"commands": [
|
||||
"agent approval-issue",
|
||||
"agent approval-list",
|
||||
"agent approval-revoke",
|
||||
],
|
||||
"supports_ttl": True,
|
||||
"supports_use_limits": True,
|
||||
"enforced_on": [
|
||||
"vault export (full, agent mode)",
|
||||
"api /api/v1/vault/export",
|
||||
"vault reveal-parent-seed (agent mode)",
|
||||
"agent get --reveal (private kinds)",
|
||||
],
|
||||
},
|
||||
"export_controls": {
|
||||
"commands": [
|
||||
"vault export --agent-profile --policy-filtered",
|
||||
"agent export-check",
|
||||
"agent export-manifest-verify",
|
||||
],
|
||||
"default_full_export_agent_profile": "denied",
|
||||
"supports_policy_filtered_export": True,
|
||||
"supports_manifest_entry_hash_verification": True,
|
||||
"manifest_mode": "policy_filtered",
|
||||
},
|
||||
"redaction": {
|
||||
"default_safe_output": True,
|
||||
"reveal_override": "agent get --reveal",
|
||||
},
|
||||
"posture": {
|
||||
"commands": ["agent posture-check", "agent posture-remediate"],
|
||||
"checks": [
|
||||
"policy validity",
|
||||
"token ttl/usage hygiene",
|
||||
"token rotation overdue against identity policy",
|
||||
"identity-token binding integrity",
|
||||
"approval gate completeness for risky actions",
|
||||
"over-permissive secret-read rule detection",
|
||||
"high-risk factor readiness",
|
||||
"high-risk unlock TTL sanity",
|
||||
"runtime config drift (quick unlock, weak KDF, partition unlock state)",
|
||||
],
|
||||
},
|
||||
"audit": {
|
||||
"log_file": "~/.seedpass/agent_audit.log",
|
||||
"integrity": "HMAC chained append-only records",
|
||||
@@ -68,13 +206,16 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"high_risk_operations": [
|
||||
"vault export",
|
||||
"vault reveal-parent-seed",
|
||||
"entry get (sensitive kinds)",
|
||||
"agent get --reveal (sensitive kinds)",
|
||||
"api /api/v1/vault/export",
|
||||
"api /api/v1/vault/backup-parent-seed",
|
||||
],
|
||||
"help_hints": [
|
||||
"Run `seedpass --help` for top-level commands.",
|
||||
"Run `seedpass <group> --help` for detailed options.",
|
||||
"Use `seedpass capabilities --format json` for machine-readable discovery.",
|
||||
"After unlock/login, run `seedpass capabilities` before autonomous runs.",
|
||||
"For agent bootstrap context, run `seedpass agent bootstrap-context`.",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -92,9 +233,24 @@ def emit_capabilities(output_format: str) -> str:
|
||||
lines.append("")
|
||||
lines.append("Security highlights:")
|
||||
lines.append("- Auth brokers: env, keyring, command, prompt")
|
||||
lines.append("- Policy controls: agent policy-show/policy-lint/policy-set")
|
||||
lines.append(
|
||||
"- Policy as code: policy-show/lint/review/apply with deterministic hashes"
|
||||
)
|
||||
lines.append("- Sync safety: deterministic conflicts with tombstones")
|
||||
lines.append("- Scoped tokens: issue/list/revoke with TTL and use limits")
|
||||
lines.append("- Agent identities: create/list/revoke and bind tokens")
|
||||
lines.append("- Secret leases: lease-only issue and bounded lease-consume")
|
||||
lines.append(
|
||||
"- Automation primitives: job-run with safe brokers and cron/systemd templates"
|
||||
)
|
||||
lines.append(
|
||||
"- Recovery hardening: Shamir split/recover and signed backup drill logs"
|
||||
)
|
||||
lines.append("- Secret class isolation: separate high-risk unlock factor/session")
|
||||
lines.append("- Approval gates: issue/list/revoke for risky actions")
|
||||
lines.append("- Deterministic export controls with manifest verification")
|
||||
lines.append("- Safe output redaction default with explicit --reveal")
|
||||
lines.append("- Security posture checks and remediation bundles")
|
||||
lines.append("- Audited access log with chained HMAC signatures")
|
||||
lines.append("")
|
||||
lines.append("Discovery hints:")
|
||||
|
||||
@@ -9,7 +9,11 @@ import typer
|
||||
import click
|
||||
|
||||
from .common import _get_entry_service, EntryType
|
||||
from seedpass.core.agent_export_policy import kind_export_allowed, load_export_policy
|
||||
from seedpass.core.agent_export_policy import (
|
||||
evaluate_kind_export,
|
||||
load_export_policy,
|
||||
record_export_policy_event,
|
||||
)
|
||||
from seedpass.core.entry_types import ALL_ENTRY_TYPES
|
||||
from utils.clipboard import ClipboardUnavailableError
|
||||
|
||||
@@ -369,9 +373,27 @@ def entry_export_totp(
|
||||
"""Export all TOTP secrets to a JSON file."""
|
||||
if agent_profile:
|
||||
policy = load_export_policy()
|
||||
if not kind_export_allowed(policy, EntryType.TOTP.value):
|
||||
typer.echo("Error: Policy denied TOTP export for agent profile.")
|
||||
allowed, reason = evaluate_kind_export(policy, EntryType.TOTP.value)
|
||||
if not allowed:
|
||||
record_export_policy_event(
|
||||
"export_denied",
|
||||
{
|
||||
"source": "cli:entry_export_totp",
|
||||
"kind": EntryType.TOTP.value,
|
||||
"reason": reason,
|
||||
"file": str(file),
|
||||
},
|
||||
)
|
||||
typer.echo(f"Error: {reason}")
|
||||
raise typer.Exit(code=1)
|
||||
record_export_policy_event(
|
||||
"export_allowed",
|
||||
{
|
||||
"source": "cli:entry_export_totp",
|
||||
"kind": EntryType.TOTP.value,
|
||||
"file": str(file),
|
||||
},
|
||||
)
|
||||
service = _get_entry_service(ctx)
|
||||
data = service.export_totp_entries()
|
||||
Path(file).write_text(json.dumps(data, indent=2))
|
||||
|
||||
+175
-10
@@ -15,15 +15,36 @@ from .common import (
|
||||
)
|
||||
from seedpass.core.agent_export_policy import (
|
||||
allowed_kinds,
|
||||
filter_index_for_allowed_kinds,
|
||||
full_export_allowed,
|
||||
build_policy_filtered_export_package,
|
||||
evaluate_full_export,
|
||||
load_export_policy,
|
||||
record_export_policy_event,
|
||||
)
|
||||
from seedpass.core.agent_approval import approval_required, consume_approval
|
||||
from seedpass.core.agent_secret_isolation import (
|
||||
high_risk_factor_configured,
|
||||
high_risk_unlocked,
|
||||
)
|
||||
from seedpass.core.auth_broker import (
|
||||
AuthBrokerError,
|
||||
resolve_password as resolve_broker_password,
|
||||
)
|
||||
from seedpass.core.auth_broker import AuthBrokerError, resolve_password as resolve_broker_password
|
||||
|
||||
app = typer.Typer(help="Manage the entire vault")
|
||||
|
||||
|
||||
def _isolation_required_for_kind(policy: dict, kind: str) -> bool:
|
||||
cfg = policy.get("secret_isolation", {})
|
||||
if not isinstance(cfg, dict):
|
||||
return False
|
||||
if not bool(cfg.get("enabled", True)):
|
||||
return False
|
||||
kinds = cfg.get("high_risk_kinds", [])
|
||||
if not isinstance(kinds, list):
|
||||
return False
|
||||
return str(kind).lower() in {str(v).lower() for v in kinds}
|
||||
|
||||
|
||||
@app.command("export")
|
||||
def vault_export(
|
||||
ctx: typer.Context,
|
||||
@@ -38,6 +59,11 @@ def vault_export(
|
||||
"--policy-filtered",
|
||||
help="Export only policy-allowed subset of entries",
|
||||
),
|
||||
approval_id: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--approval-id",
|
||||
help="Step-up approval id for actions requiring approval",
|
||||
),
|
||||
) -> None:
|
||||
"""Export the vault profile to an encrypted file."""
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
@@ -48,20 +74,94 @@ def vault_export(
|
||||
vault = getattr(manager, "vault", None)
|
||||
enc_mgr = getattr(vault, "encryption_manager", None)
|
||||
if vault is None or enc_mgr is None:
|
||||
typer.echo("Error: policy-filtered export unavailable in current context")
|
||||
typer.echo(
|
||||
"Error: policy-filtered export unavailable in current context"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
index_data = vault.load_index()
|
||||
filtered = filter_index_for_allowed_kinds(index_data, allowed_kinds(policy))
|
||||
filtered = build_policy_filtered_export_package(index_data, policy)
|
||||
payload = json.dumps(
|
||||
filtered, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
data = enc_mgr.encrypt_data(payload)
|
||||
Path(file).write_bytes(data)
|
||||
record_export_policy_event(
|
||||
"export_allowed",
|
||||
{
|
||||
"source": "cli:vault_export",
|
||||
"mode": "filtered",
|
||||
"file": str(file),
|
||||
"allowed_kinds": sorted(list(allowed_kinds(policy))),
|
||||
"policy_stamp": filtered.get("_export_manifest", {}).get(
|
||||
"policy_stamp"
|
||||
),
|
||||
},
|
||||
)
|
||||
typer.echo(str(file))
|
||||
return
|
||||
if not full_export_allowed(policy):
|
||||
allowed, reason = evaluate_full_export(policy)
|
||||
if not allowed:
|
||||
record_export_policy_event(
|
||||
"export_denied",
|
||||
{
|
||||
"source": "cli:vault_export",
|
||||
"mode": "full",
|
||||
"reason": reason,
|
||||
"file": str(file),
|
||||
},
|
||||
)
|
||||
typer.echo(f"Error: {reason}. Use --policy-filtered for subset export.")
|
||||
raise typer.Exit(code=1)
|
||||
if approval_required(policy, "export"):
|
||||
if not approval_id:
|
||||
reason = "policy_deny:approval_required"
|
||||
record_export_policy_event(
|
||||
"export_denied",
|
||||
{
|
||||
"source": "cli:vault_export",
|
||||
"mode": "full",
|
||||
"reason": reason,
|
||||
"file": str(file),
|
||||
},
|
||||
)
|
||||
typer.echo(f"Error: {reason}. Provide --approval-id for full export.")
|
||||
raise typer.Exit(code=1)
|
||||
ok, approval_reason = consume_approval(
|
||||
approval_id=approval_id,
|
||||
action="export",
|
||||
resource="vault:full",
|
||||
)
|
||||
if not ok:
|
||||
reason = f"policy_deny:{approval_reason}"
|
||||
record_export_policy_event(
|
||||
"export_denied",
|
||||
{
|
||||
"source": "cli:vault_export",
|
||||
"mode": "full",
|
||||
"reason": reason,
|
||||
"file": str(file),
|
||||
},
|
||||
)
|
||||
typer.echo(f"Error: {reason}")
|
||||
raise typer.Exit(code=1)
|
||||
record_export_policy_event(
|
||||
"export_allowed",
|
||||
{
|
||||
"source": "cli:vault_export",
|
||||
"mode": "full",
|
||||
"file": str(file),
|
||||
"approval_id": approval_id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
policy = load_export_policy()
|
||||
|
||||
if _isolation_required_for_kind(policy, "seed") and high_risk_factor_configured():
|
||||
fingerprint = str((ctx.obj or {}).get("fingerprint") or "default")
|
||||
unlocked, _expires_at = high_risk_unlocked(fingerprint=fingerprint)
|
||||
if not unlocked:
|
||||
typer.echo(
|
||||
"Error: Policy denied full vault export for agent profile. Use --policy-filtered."
|
||||
"Error: policy_deny:high_risk_locked. Unlock high-risk session first."
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
data = vault_service.export_profile()
|
||||
@@ -103,10 +203,14 @@ def vault_unlock(
|
||||
"prompt",
|
||||
"--auth-broker",
|
||||
help="Password source for unlock (prompt|env|keyring|command)",
|
||||
click_type=click.Choice(["prompt", "env", "keyring", "command"], case_sensitive=False),
|
||||
click_type=click.Choice(
|
||||
["prompt", "env", "keyring", "command"], case_sensitive=False
|
||||
),
|
||||
),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var used when --auth-broker=env"
|
||||
"SEEDPASS_PASSWORD",
|
||||
"--password-env",
|
||||
help="Env var used when --auth-broker=env",
|
||||
),
|
||||
broker_service: str = typer.Option(
|
||||
"seedpass",
|
||||
@@ -148,7 +252,7 @@ def vault_unlock(
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(f"Unlocked in {resp.duration:.2f}s")
|
||||
typer.echo(
|
||||
"Tip: run `seedpass --help` (and `seedpass <command> --help`) to discover available features."
|
||||
"Tip: run `seedpass --help`, `seedpass capabilities`, and `seedpass <command> --help` to discover features."
|
||||
)
|
||||
|
||||
|
||||
@@ -174,10 +278,71 @@ def vault_reveal_parent_seed(
|
||||
file: Optional[str] = typer.Option(
|
||||
None, "--file", help="Save encrypted seed to this path"
|
||||
),
|
||||
agent_profile: bool = typer.Option(
|
||||
False,
|
||||
"--agent-profile",
|
||||
help="Apply agent approval policy controls",
|
||||
),
|
||||
approval_id: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--approval-id",
|
||||
help="Step-up approval id for reveal-parent-seed when required",
|
||||
),
|
||||
) -> None:
|
||||
"""Display the parent seed and optionally write an encrypted backup file."""
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
policy = load_export_policy()
|
||||
if _isolation_required_for_kind(policy, "seed") and high_risk_factor_configured():
|
||||
fingerprint = str((ctx.obj or {}).get("fingerprint") or "default")
|
||||
unlocked, _expires_at = high_risk_unlocked(fingerprint=fingerprint)
|
||||
if not unlocked:
|
||||
typer.echo(
|
||||
"Error: policy_deny:high_risk_locked. Unlock high-risk session first."
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
if agent_profile:
|
||||
if approval_required(policy, "reveal_parent_seed"):
|
||||
if not approval_id:
|
||||
reason = "policy_deny:approval_required"
|
||||
record_export_policy_event(
|
||||
"approval_denied",
|
||||
{
|
||||
"source": "cli:vault_reveal_parent_seed",
|
||||
"reason": reason,
|
||||
"action": "reveal_parent_seed",
|
||||
},
|
||||
)
|
||||
typer.echo(f"Error: {reason}. Provide --approval-id.")
|
||||
raise typer.Exit(code=1)
|
||||
ok, approval_reason = consume_approval(
|
||||
approval_id=approval_id,
|
||||
action="reveal_parent_seed",
|
||||
resource="vault:parent-seed",
|
||||
)
|
||||
if not ok:
|
||||
reason = f"policy_deny:{approval_reason}"
|
||||
record_export_policy_event(
|
||||
"approval_denied",
|
||||
{
|
||||
"source": "cli:vault_reveal_parent_seed",
|
||||
"reason": reason,
|
||||
"action": "reveal_parent_seed",
|
||||
"approval_id": approval_id,
|
||||
},
|
||||
)
|
||||
typer.echo(f"Error: {reason}")
|
||||
raise typer.Exit(code=1)
|
||||
password = typer.prompt("Master password", hide_input=True)
|
||||
vault_service.backup_parent_seed(
|
||||
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
|
||||
)
|
||||
if agent_profile:
|
||||
record_export_policy_event(
|
||||
"approval_consumed",
|
||||
{
|
||||
"source": "cli:vault_reveal_parent_seed",
|
||||
"action": "reveal_parent_seed",
|
||||
"approval_id": approval_id,
|
||||
"file": str(file) if file else None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import APP_DIR
|
||||
|
||||
APPROVAL_STORE_VERSION = 1
|
||||
VALID_APPROVAL_ACTIONS = {
|
||||
"export",
|
||||
"reveal_parent_seed",
|
||||
"private_key_retrieval",
|
||||
}
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return APP_DIR / "agent_approvals.json"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str) -> datetime:
|
||||
dt = datetime.fromisoformat(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _load_store() -> dict[str, Any]:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return {"version": APPROVAL_STORE_VERSION, "approvals": []}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"version": APPROVAL_STORE_VERSION, "approvals": []}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("approvals"), list):
|
||||
return {"version": APPROVAL_STORE_VERSION, "approvals": []}
|
||||
return data
|
||||
|
||||
|
||||
def _save_store(store: dict[str, Any]) -> None:
|
||||
path = _store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def issue_approval(
|
||||
*,
|
||||
action: str,
|
||||
ttl_seconds: int,
|
||||
uses: int,
|
||||
resource: str = "*",
|
||||
issued_by: str = "manual",
|
||||
) -> dict[str, Any]:
|
||||
store = _load_store()
|
||||
now = _utcnow()
|
||||
approval_id = secrets.token_urlsafe(18)
|
||||
rec = {
|
||||
"id": approval_id,
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"issued_by": issued_by,
|
||||
"created_at_utc": now.isoformat(),
|
||||
"expires_at_utc": (now + timedelta(seconds=ttl_seconds)).isoformat(),
|
||||
"uses_remaining": int(uses),
|
||||
"revoked_at_utc": None,
|
||||
}
|
||||
store["approvals"].append(rec)
|
||||
_save_store(store)
|
||||
return rec
|
||||
|
||||
|
||||
def approval_required(policy: dict[str, Any], action: str) -> bool:
|
||||
approvals_cfg = policy.get("approvals", {})
|
||||
if not isinstance(approvals_cfg, dict):
|
||||
return False
|
||||
required = approvals_cfg.get("require_for", [])
|
||||
if not isinstance(required, list):
|
||||
return False
|
||||
action_l = action.strip().lower()
|
||||
return any(str(v).strip().lower() == action_l for v in required)
|
||||
|
||||
|
||||
def list_approvals(*, include_revoked: bool = False) -> list[dict[str, Any]]:
|
||||
approvals = _load_store().get("approvals", [])
|
||||
if include_revoked:
|
||||
return approvals
|
||||
return [a for a in approvals if not a.get("revoked_at_utc")]
|
||||
|
||||
|
||||
def revoke_approval(approval_id: str) -> bool:
|
||||
store = _load_store()
|
||||
now = _utcnow().isoformat()
|
||||
changed = False
|
||||
for rec in store.get("approvals", []):
|
||||
if rec.get("id") == approval_id and not rec.get("revoked_at_utc"):
|
||||
rec["revoked_at_utc"] = now
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
_save_store(store)
|
||||
return changed
|
||||
|
||||
|
||||
def consume_approval(
|
||||
*,
|
||||
approval_id: str,
|
||||
action: str,
|
||||
resource: str = "*",
|
||||
) -> tuple[bool, str]:
|
||||
store = _load_store()
|
||||
now = _utcnow()
|
||||
for rec in store.get("approvals", []):
|
||||
if rec.get("id") != approval_id:
|
||||
continue
|
||||
if rec.get("revoked_at_utc"):
|
||||
return False, "approval_revoked"
|
||||
if str(rec.get("action", "")).lower() != action.lower():
|
||||
return False, "approval_action_mismatch"
|
||||
rec_resource = str(rec.get("resource", "*"))
|
||||
if rec_resource not in {"*", resource}:
|
||||
return False, "approval_resource_mismatch"
|
||||
expires = rec.get("expires_at_utc")
|
||||
if not expires or _parse_iso(expires) <= now:
|
||||
return False, "approval_expired"
|
||||
uses = int(rec.get("uses_remaining", 0))
|
||||
if uses <= 0:
|
||||
return False, "approval_exhausted"
|
||||
rec["uses_remaining"] = uses - 1
|
||||
_save_store(store)
|
||||
return True, "approval_consumed"
|
||||
return False, "approval_not_found"
|
||||
@@ -1,6 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,13 +21,96 @@ DEFAULT_EXPORT_POLICY: dict[str, Any] = {
|
||||
],
|
||||
"allow_export_import": False,
|
||||
"export": {"allow_full_vault": False},
|
||||
"approvals": {
|
||||
"require_for": ["export", "reveal_parent_seed", "private_key_retrieval"]
|
||||
},
|
||||
"secret_isolation": {
|
||||
"enabled": True,
|
||||
"high_risk_kinds": [
|
||||
EntryType.SEED.value,
|
||||
EntryType.SSH.value,
|
||||
EntryType.PGP.value,
|
||||
EntryType.NOSTR.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
],
|
||||
"unlock_ttl_sec": 300,
|
||||
},
|
||||
}
|
||||
|
||||
REASON_FULL_EXPORT_BLOCKED = "policy_deny:full_export_blocked"
|
||||
REASON_KIND_NOT_ALLOWED = "policy_deny:kind_not_allowed"
|
||||
REASON_EXPORT_ALLOWED = "policy_allow:export_allowed"
|
||||
REASON_KIND_ALLOWED = "policy_allow:kind_allowed"
|
||||
MANIFEST_VERSION = 2
|
||||
REDACTED_SENTINEL = "[REDACTED_BY_POLICY]"
|
||||
|
||||
|
||||
def _policy_path() -> Path:
|
||||
return APP_DIR / "agent_policy.json"
|
||||
|
||||
|
||||
def _audit_key_path() -> Path:
|
||||
return APP_DIR / "agent_export_audit.key"
|
||||
|
||||
|
||||
def _audit_log_path() -> Path:
|
||||
return APP_DIR / "agent_export_audit.log"
|
||||
|
||||
|
||||
def _ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _load_or_create_audit_key() -> bytes:
|
||||
path = _audit_key_path()
|
||||
if path.exists():
|
||||
return path.read_bytes()
|
||||
_ensure_parent(path)
|
||||
key = secrets.token_bytes(32)
|
||||
path.write_bytes(key)
|
||||
os.chmod(path, 0o600)
|
||||
return key
|
||||
|
||||
|
||||
def _previous_signature() -> str:
|
||||
path = _audit_log_path()
|
||||
if not path.exists():
|
||||
return "0" * 64
|
||||
try:
|
||||
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln]
|
||||
if not lines:
|
||||
return "0" * 64
|
||||
last = json.loads(lines[-1])
|
||||
return str(last.get("sig", "0" * 64))
|
||||
except Exception:
|
||||
return "0" * 64
|
||||
|
||||
|
||||
def record_export_policy_event(event: str, details: dict[str, Any]) -> None:
|
||||
"""Append signed export policy event to audit log (best-effort)."""
|
||||
key = _load_or_create_audit_key()
|
||||
prev_sig = _previous_signature()
|
||||
payload = {
|
||||
"timestamp_utc": _utcnow_iso(),
|
||||
"host": socket.gethostname(),
|
||||
"event": event,
|
||||
"details": details,
|
||||
}
|
||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
payload["sig"] = hmac.new(
|
||||
key, f"{prev_sig}{canonical}".encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
log_path = _audit_log_path()
|
||||
_ensure_parent(log_path)
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
os.chmod(log_path, 0o600)
|
||||
|
||||
|
||||
def load_export_policy() -> dict[str, Any]:
|
||||
path = _policy_path()
|
||||
if not path.exists():
|
||||
@@ -48,6 +137,50 @@ def load_export_policy() -> dict[str, Any]:
|
||||
}
|
||||
elif isinstance(data.get("allow_export_import"), bool):
|
||||
policy["export"] = {"allow_full_vault": bool(data["allow_export_import"])}
|
||||
approvals_cfg = data.get("approvals")
|
||||
if isinstance(approvals_cfg, dict):
|
||||
require_for = approvals_cfg.get("require_for")
|
||||
if isinstance(require_for, list):
|
||||
policy["approvals"] = {
|
||||
"require_for": [
|
||||
str(v).strip().lower() for v in require_for if str(v).strip()
|
||||
]
|
||||
}
|
||||
isolation_cfg = data.get("secret_isolation")
|
||||
if isinstance(isolation_cfg, dict):
|
||||
raw_high_risk = isolation_cfg.get("high_risk_kinds")
|
||||
if isinstance(raw_high_risk, list):
|
||||
high_risk_kinds = [
|
||||
str(v).strip().lower()
|
||||
for v in raw_high_risk
|
||||
if str(v).strip().lower()
|
||||
in {
|
||||
EntryType.SEED.value,
|
||||
EntryType.SSH.value,
|
||||
EntryType.PGP.value,
|
||||
EntryType.NOSTR.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
}
|
||||
]
|
||||
else:
|
||||
high_risk_kinds = list(
|
||||
DEFAULT_EXPORT_POLICY["secret_isolation"]["high_risk_kinds"]
|
||||
)
|
||||
unlock_ttl_sec = int(
|
||||
isolation_cfg.get(
|
||||
"unlock_ttl_sec",
|
||||
DEFAULT_EXPORT_POLICY["secret_isolation"]["unlock_ttl_sec"],
|
||||
)
|
||||
)
|
||||
if unlock_ttl_sec < 1:
|
||||
unlock_ttl_sec = int(
|
||||
DEFAULT_EXPORT_POLICY["secret_isolation"]["unlock_ttl_sec"]
|
||||
)
|
||||
policy["secret_isolation"] = {
|
||||
"enabled": bool(isolation_cfg.get("enabled", True)),
|
||||
"high_risk_kinds": high_risk_kinds,
|
||||
"unlock_ttl_sec": unlock_ttl_sec,
|
||||
}
|
||||
return policy
|
||||
|
||||
|
||||
@@ -69,7 +202,21 @@ def kind_export_allowed(policy: dict[str, Any], kind: str) -> bool:
|
||||
return kind in allowed_kinds(policy)
|
||||
|
||||
|
||||
def filter_index_for_allowed_kinds(index_data: dict[str, Any], kinds: set[str]) -> dict[str, Any]:
|
||||
def evaluate_full_export(policy: dict[str, Any]) -> tuple[bool, str]:
|
||||
if full_export_allowed(policy):
|
||||
return True, REASON_EXPORT_ALLOWED
|
||||
return False, REASON_FULL_EXPORT_BLOCKED
|
||||
|
||||
|
||||
def evaluate_kind_export(policy: dict[str, Any], kind: str) -> tuple[bool, str]:
|
||||
if kind_export_allowed(policy, kind):
|
||||
return True, REASON_KIND_ALLOWED
|
||||
return False, REASON_KIND_NOT_ALLOWED
|
||||
|
||||
|
||||
def filter_index_for_allowed_kinds(
|
||||
index_data: dict[str, Any], kinds: set[str]
|
||||
) -> dict[str, Any]:
|
||||
entries = index_data.get("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
entries = {}
|
||||
@@ -84,3 +231,208 @@ def filter_index_for_allowed_kinds(index_data: dict[str, Any], kinds: set[str])
|
||||
"schema_version": int(index_data.get("schema_version", 1)),
|
||||
"entries": filtered_entries,
|
||||
}
|
||||
|
||||
|
||||
def _policy_stamp(policy: dict[str, Any]) -> str:
|
||||
material = {
|
||||
"allow_kinds": sorted(list(allowed_kinds(policy))),
|
||||
"allow_export_import": bool(policy.get("allow_export_import", False)),
|
||||
"allow_full_vault": bool(
|
||||
policy.get("export", {}).get("allow_full_vault", False)
|
||||
),
|
||||
"safe_output_default": bool(
|
||||
policy.get("output", {}).get("safe_output_default", True)
|
||||
),
|
||||
"redact_fields": sorted(
|
||||
[str(v) for v in policy.get("output", {}).get("redact_fields", [])]
|
||||
),
|
||||
"secret_isolation_enabled": bool(
|
||||
policy.get("secret_isolation", {}).get("enabled", True)
|
||||
),
|
||||
"secret_isolation_high_risk_kinds": sorted(
|
||||
[
|
||||
str(v)
|
||||
for v in policy.get("secret_isolation", {}).get("high_risk_kinds", [])
|
||||
]
|
||||
),
|
||||
}
|
||||
canonical = json.dumps(material, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _safe_int(value: Any, *, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _sorted_index_keys(indexes: list[str]) -> list[str]:
|
||||
def _key(value: str) -> tuple[int, int, str]:
|
||||
try:
|
||||
return (0, int(value), value)
|
||||
except Exception:
|
||||
return (1, 0, value)
|
||||
|
||||
return sorted([str(v) for v in indexes], key=_key)
|
||||
|
||||
|
||||
def compute_policy_stamp(policy: dict[str, Any]) -> str:
|
||||
"""Return deterministic hash over export-relevant policy controls."""
|
||||
return _policy_stamp(policy)
|
||||
|
||||
|
||||
def _redacted_copy(entry: dict[str, Any], redacted_fields: set[str]) -> dict[str, Any]:
|
||||
copied: dict[str, Any] = {}
|
||||
for key, value in entry.items():
|
||||
if key in redacted_fields:
|
||||
copied[key] = REDACTED_SENTINEL
|
||||
else:
|
||||
copied[key] = value
|
||||
return copied
|
||||
|
||||
|
||||
def build_policy_filtered_export_package(
|
||||
index_data: dict[str, Any], policy: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
kinds = allowed_kinds(policy)
|
||||
redacted_fields = {
|
||||
str(v) for v in policy.get("output", {}).get("redact_fields", []) if str(v)
|
||||
}
|
||||
entries = index_data.get("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
entries = {}
|
||||
|
||||
filtered_entries: dict[str, dict[str, Any]] = {}
|
||||
excluded = 0
|
||||
for idx, entry in entries.items():
|
||||
if not isinstance(entry, dict):
|
||||
excluded += 1
|
||||
continue
|
||||
kind = str(entry.get("kind", entry.get("type", "")))
|
||||
if kind not in kinds:
|
||||
excluded += 1
|
||||
continue
|
||||
filtered_entries[str(idx)] = _redacted_copy(entry, redacted_fields)
|
||||
sorted_indexes = _sorted_index_keys(list(filtered_entries.keys()))
|
||||
entries_canonical = _canonical_json(
|
||||
{idx: filtered_entries[idx] for idx in sorted_indexes}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"manifest_version": MANIFEST_VERSION,
|
||||
"mode": "policy_filtered",
|
||||
"policy_stamp": _policy_stamp(policy),
|
||||
"allow_kinds": sorted(list(kinds)),
|
||||
"redacted_fields": sorted(list(redacted_fields)),
|
||||
"safe_output_default": bool(
|
||||
policy.get("output", {}).get("safe_output_default", True)
|
||||
),
|
||||
"included_entry_count": len(filtered_entries),
|
||||
"excluded_entry_count": int(excluded),
|
||||
"included_entry_indexes": sorted_indexes,
|
||||
"entries_sha256": hashlib.sha256(entries_canonical.encode("utf-8")).hexdigest(),
|
||||
"source_schema_version": int(index_data.get("schema_version", 1)),
|
||||
}
|
||||
return {
|
||||
"schema_version": int(index_data.get("schema_version", 1)),
|
||||
"_export_manifest": manifest,
|
||||
"entries": filtered_entries,
|
||||
}
|
||||
|
||||
|
||||
def verify_filtered_export_package(
|
||||
package: dict[str, Any], policy: dict[str, Any]
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Verify filtered export package shape and policy stamp compatibility."""
|
||||
errors: list[str] = []
|
||||
if not isinstance(package, dict):
|
||||
return False, ["package_not_object"]
|
||||
manifest = package.get("_export_manifest")
|
||||
if not isinstance(manifest, dict):
|
||||
return False, ["missing_manifest"]
|
||||
if manifest.get("mode") != "policy_filtered":
|
||||
errors.append("invalid_mode")
|
||||
manifest_version = _safe_int(manifest.get("manifest_version", 0), default=0)
|
||||
if manifest_version not in {1, MANIFEST_VERSION}:
|
||||
errors.append("invalid_manifest_version")
|
||||
entries = package.get("entries")
|
||||
if not isinstance(entries, dict):
|
||||
errors.append("entries_not_object")
|
||||
entries = {}
|
||||
|
||||
expected_stamp = compute_policy_stamp(policy)
|
||||
if str(manifest.get("policy_stamp", "")) != expected_stamp:
|
||||
errors.append("policy_stamp_mismatch")
|
||||
|
||||
listed_indexes = manifest.get("included_entry_indexes", [])
|
||||
if not isinstance(listed_indexes, list):
|
||||
errors.append("invalid_included_entry_indexes")
|
||||
listed_indexes = []
|
||||
listed_indexes_str = [str(v) for v in listed_indexes]
|
||||
actual_indexes = _sorted_index_keys(list(entries.keys()))
|
||||
if listed_indexes_str != actual_indexes:
|
||||
errors.append("included_entry_indexes_mismatch")
|
||||
|
||||
included_count = _safe_int(manifest.get("included_entry_count", -1), default=-1)
|
||||
if included_count != len(actual_indexes):
|
||||
errors.append("included_entry_count_mismatch")
|
||||
|
||||
excluded_count = _safe_int(manifest.get("excluded_entry_count", -1), default=-1)
|
||||
if excluded_count < 0:
|
||||
errors.append("invalid_excluded_entry_count")
|
||||
|
||||
allow_kinds_manifest = manifest.get("allow_kinds", [])
|
||||
if not isinstance(allow_kinds_manifest, list):
|
||||
errors.append("invalid_allow_kinds")
|
||||
allow_kinds_manifest = []
|
||||
expected_kinds = sorted(list(allowed_kinds(policy)))
|
||||
if sorted([str(v) for v in allow_kinds_manifest]) != expected_kinds:
|
||||
errors.append("allow_kinds_mismatch")
|
||||
|
||||
manifest_redacted_fields = manifest.get("redacted_fields", [])
|
||||
if not isinstance(manifest_redacted_fields, list):
|
||||
errors.append("invalid_redacted_fields")
|
||||
manifest_redacted_fields = []
|
||||
expected_redacted_fields = sorted(
|
||||
[str(v) for v in policy.get("output", {}).get("redact_fields", []) if str(v)]
|
||||
)
|
||||
if sorted([str(v) for v in manifest_redacted_fields]) != expected_redacted_fields:
|
||||
errors.append("redacted_fields_mismatch")
|
||||
|
||||
source_schema_version = _safe_int(
|
||||
manifest.get("source_schema_version", -1), default=-1
|
||||
)
|
||||
package_schema_version = _safe_int(package.get("schema_version", -2), default=-2)
|
||||
if package_schema_version != source_schema_version:
|
||||
errors.append("source_schema_version_mismatch")
|
||||
|
||||
allowed_kind_set = set(expected_kinds)
|
||||
redacted_fields_set = set(expected_redacted_fields)
|
||||
for idx, entry in entries.items():
|
||||
if not isinstance(entry, dict):
|
||||
errors.append("entry_not_object")
|
||||
continue
|
||||
kind = str(entry.get("kind", entry.get("type", "")))
|
||||
if kind not in allowed_kind_set:
|
||||
errors.append("entry_kind_not_allowed")
|
||||
break
|
||||
for field in redacted_fields_set:
|
||||
if field in entry and entry.get(field) != REDACTED_SENTINEL:
|
||||
errors.append("entry_redaction_mismatch")
|
||||
break
|
||||
|
||||
if manifest_version >= 2:
|
||||
expected_entries_sha = hashlib.sha256(
|
||||
_canonical_json(
|
||||
{idx: entries[idx] for idx in _sorted_index_keys(list(entries.keys()))}
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
if str(manifest.get("entries_sha256", "")) != expected_entries_sha:
|
||||
errors.append("entries_hash_mismatch")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import APP_DIR
|
||||
|
||||
IDENTITY_STORE_VERSION = 1
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return APP_DIR / "agent_identities.json"
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _empty_store() -> dict[str, Any]:
|
||||
return {"version": IDENTITY_STORE_VERSION, "identities": []}
|
||||
|
||||
|
||||
def _load_store() -> dict[str, Any]:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return _empty_store()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return _empty_store()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("identities"), list):
|
||||
return _empty_store()
|
||||
return data
|
||||
|
||||
|
||||
def _save_store(store: dict[str, Any]) -> None:
|
||||
path = _store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def list_identities(*, include_revoked: bool = False) -> list[dict[str, Any]]:
|
||||
identities = _load_store().get("identities", [])
|
||||
if include_revoked:
|
||||
return identities
|
||||
return [v for v in identities if not v.get("revoked_at_utc")]
|
||||
|
||||
|
||||
def get_identity(identity_id: str) -> dict[str, Any] | None:
|
||||
target = str(identity_id).strip()
|
||||
if not target:
|
||||
return None
|
||||
for rec in _load_store().get("identities", []):
|
||||
if str(rec.get("id", "")) == target:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def create_identity(
|
||||
*,
|
||||
identity_id: str,
|
||||
owner: str,
|
||||
policy_binding: str = "default",
|
||||
rotation_days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
identity_id = str(identity_id).strip()
|
||||
if not identity_id:
|
||||
raise ValueError("identity_id_required")
|
||||
if get_identity(identity_id):
|
||||
raise ValueError("identity_exists")
|
||||
|
||||
rec = {
|
||||
"id": identity_id,
|
||||
"owner": str(owner).strip() or "unowned",
|
||||
"policy_binding": str(policy_binding).strip() or "default",
|
||||
"rotation_days": int(rotation_days),
|
||||
"created_at_utc": _utcnow_iso(),
|
||||
"revoked_at_utc": None,
|
||||
}
|
||||
store = _load_store()
|
||||
store.setdefault("version", IDENTITY_STORE_VERSION)
|
||||
store.setdefault("identities", [])
|
||||
store["identities"].append(rec)
|
||||
_save_store(store)
|
||||
return rec
|
||||
|
||||
|
||||
def ensure_identity(
|
||||
identity_id: str,
|
||||
*,
|
||||
owner: str = "system",
|
||||
policy_binding: str = "default",
|
||||
rotation_days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
existing = get_identity(identity_id)
|
||||
if existing:
|
||||
return existing
|
||||
return create_identity(
|
||||
identity_id=identity_id,
|
||||
owner=owner,
|
||||
policy_binding=policy_binding,
|
||||
rotation_days=rotation_days,
|
||||
)
|
||||
|
||||
|
||||
def revoke_identity(identity_id: str) -> bool:
|
||||
identity_id = str(identity_id).strip()
|
||||
if not identity_id:
|
||||
return False
|
||||
store = _load_store()
|
||||
changed = False
|
||||
now = _utcnow_iso()
|
||||
for rec in store.get("identities", []):
|
||||
if str(rec.get("id", "")) == identity_id and not rec.get("revoked_at_utc"):
|
||||
rec["revoked_at_utc"] = now
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
_save_store(store)
|
||||
return changed
|
||||
|
||||
|
||||
def identity_active(identity_id: str) -> bool:
|
||||
rec = get_identity(identity_id)
|
||||
if not rec:
|
||||
return False
|
||||
return not bool(rec.get("revoked_at_utc"))
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import APP_DIR
|
||||
|
||||
JOB_STORE_VERSION = 1
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return APP_DIR / "agent_jobs.json"
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _empty_store() -> dict[str, Any]:
|
||||
return {"version": JOB_STORE_VERSION, "jobs": []}
|
||||
|
||||
|
||||
def _load_store() -> dict[str, Any]:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return _empty_store()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return _empty_store()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("jobs"), list):
|
||||
return _empty_store()
|
||||
return data
|
||||
|
||||
|
||||
def _save_store(store: dict[str, Any]) -> None:
|
||||
path = _store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def list_job_profiles(*, include_revoked: bool = False) -> list[dict[str, Any]]:
|
||||
jobs = _load_store().get("jobs", [])
|
||||
if include_revoked:
|
||||
return jobs
|
||||
return [v for v in jobs if not v.get("revoked_at_utc")]
|
||||
|
||||
|
||||
def get_job_profile(job_id: str) -> dict[str, Any] | None:
|
||||
target = str(job_id).strip()
|
||||
if not target:
|
||||
return None
|
||||
for rec in _load_store().get("jobs", []):
|
||||
if str(rec.get("id", "")) == target:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def create_job_profile(
|
||||
*,
|
||||
job_id: str,
|
||||
fingerprint: str,
|
||||
query: str,
|
||||
auth_broker: str,
|
||||
broker_service: str,
|
||||
broker_account: str,
|
||||
broker_command: str | None,
|
||||
policy_binding: str,
|
||||
policy_stamp: str,
|
||||
schedule: str,
|
||||
description: str,
|
||||
host_binding: str,
|
||||
lease_only: bool,
|
||||
lease_ttl: int,
|
||||
lease_uses: int,
|
||||
reveal: bool,
|
||||
) -> dict[str, Any]:
|
||||
job_id = str(job_id).strip()
|
||||
if not job_id:
|
||||
raise ValueError("job_id_required")
|
||||
if get_job_profile(job_id):
|
||||
raise ValueError("job_exists")
|
||||
rec = {
|
||||
"id": job_id,
|
||||
"fingerprint": str(fingerprint).strip(),
|
||||
"query": str(query),
|
||||
"auth_broker": str(auth_broker).strip().lower(),
|
||||
"broker_service": str(broker_service).strip(),
|
||||
"broker_account": str(broker_account).strip(),
|
||||
"broker_command": str(broker_command or "").strip() or None,
|
||||
"policy_binding": str(policy_binding).strip() or "default",
|
||||
"policy_stamp": str(policy_stamp).strip(),
|
||||
"schedule": str(schedule).strip(),
|
||||
"description": str(description).strip(),
|
||||
"host_binding": str(host_binding).strip(),
|
||||
"lease_only": bool(lease_only),
|
||||
"lease_ttl": int(lease_ttl),
|
||||
"lease_uses": int(lease_uses),
|
||||
"reveal": bool(reveal),
|
||||
"created_at_utc": _utcnow_iso(),
|
||||
"revoked_at_utc": None,
|
||||
}
|
||||
store = _load_store()
|
||||
store.setdefault("version", JOB_STORE_VERSION)
|
||||
store.setdefault("jobs", [])
|
||||
store["jobs"].append(rec)
|
||||
_save_store(store)
|
||||
return rec
|
||||
|
||||
|
||||
def revoke_job_profile(job_id: str) -> bool:
|
||||
job_id = str(job_id).strip()
|
||||
if not job_id:
|
||||
return False
|
||||
store = _load_store()
|
||||
changed = False
|
||||
now = _utcnow_iso()
|
||||
for rec in store.get("jobs", []):
|
||||
if str(rec.get("id", "")) == job_id and not rec.get("revoked_at_utc"):
|
||||
rec["revoked_at_utc"] = now
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
_save_store(store)
|
||||
return changed
|
||||
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import APP_DIR
|
||||
|
||||
PRIME = 257
|
||||
SHARE_PREFIX = "sprec1"
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _mod_inv(value: int) -> int:
|
||||
return pow(value % PRIME, -1, PRIME)
|
||||
|
||||
|
||||
def _coef(secret: bytes, label: str, byte_idx: int, power: int) -> int:
|
||||
# Deterministic coefficient derivation for reproducible share generation.
|
||||
msg = f"{label}:{byte_idx}:{power}".encode("utf-8")
|
||||
digest = hmac.new(secret, msg, hashlib.sha256).digest()
|
||||
return int.from_bytes(digest[:2], "big") % PRIME
|
||||
|
||||
|
||||
def _poly_eval_at_x(secret_byte: int, coeffs: list[int], x: int) -> int:
|
||||
total = secret_byte % PRIME
|
||||
x_pow = x % PRIME
|
||||
for coeff in coeffs:
|
||||
total = (total + (coeff * x_pow)) % PRIME
|
||||
x_pow = (x_pow * x) % PRIME
|
||||
return total
|
||||
|
||||
|
||||
def _parse_share_token(token: str) -> dict[str, Any]:
|
||||
parts = str(token).split(":", 6)
|
||||
if len(parts) != 7 or parts[0] != SHARE_PREFIX:
|
||||
raise ValueError("invalid_share_format")
|
||||
_prefix, label, threshold, total, index, digest, payload = parts
|
||||
try:
|
||||
threshold_i = int(threshold)
|
||||
total_i = int(total)
|
||||
index_i = int(index)
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid_share_metadata") from exc
|
||||
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
|
||||
check = hashlib.sha256(raw).hexdigest()[:16]
|
||||
if check != digest:
|
||||
raise ValueError("invalid_share_checksum")
|
||||
if len(raw) % 2 != 0:
|
||||
raise ValueError("invalid_share_payload")
|
||||
values = []
|
||||
for i in range(0, len(raw), 2):
|
||||
v = int.from_bytes(raw[i : i + 2], "big")
|
||||
if v < 0 or v >= PRIME:
|
||||
raise ValueError("invalid_share_value")
|
||||
values.append(v)
|
||||
return {
|
||||
"label": label,
|
||||
"threshold": threshold_i,
|
||||
"total": total_i,
|
||||
"index": index_i,
|
||||
"values": values,
|
||||
}
|
||||
|
||||
|
||||
def split_secret(
|
||||
secret: str, *, total_shares: int, threshold: int, label: str = "default"
|
||||
) -> list[str]:
|
||||
secret_bytes = secret.encode("utf-8")
|
||||
if threshold < 2:
|
||||
raise ValueError("threshold_must_be_at_least_2")
|
||||
if total_shares < threshold:
|
||||
raise ValueError("total_shares_must_be_gte_threshold")
|
||||
if total_shares > 32:
|
||||
raise ValueError("total_shares_too_large")
|
||||
if not secret_bytes:
|
||||
raise ValueError("secret_required")
|
||||
label_value = str(label).strip() or "default"
|
||||
|
||||
shares: list[str] = []
|
||||
for x in range(1, total_shares + 1):
|
||||
vals: list[int] = []
|
||||
for idx, b in enumerate(secret_bytes):
|
||||
coeffs = [
|
||||
_coef(secret_bytes, label_value, idx, p) for p in range(1, threshold)
|
||||
]
|
||||
vals.append(_poly_eval_at_x(int(b), coeffs, x))
|
||||
raw = b"".join(int(v).to_bytes(2, "big") for v in vals)
|
||||
digest = hashlib.sha256(raw).hexdigest()[:16]
|
||||
payload = base64.urlsafe_b64encode(raw).decode("ascii")
|
||||
token = (
|
||||
f"{SHARE_PREFIX}:{label_value}:{threshold}:{total_shares}:"
|
||||
f"{x}:{digest}:{payload}"
|
||||
)
|
||||
shares.append(token)
|
||||
return shares
|
||||
|
||||
|
||||
def recover_secret(tokens: list[str]) -> str:
|
||||
if not tokens:
|
||||
raise ValueError("shares_required")
|
||||
parsed = [_parse_share_token(t) for t in tokens]
|
||||
first = parsed[0]
|
||||
label = str(first["label"])
|
||||
threshold = int(first["threshold"])
|
||||
expected_len = len(first["values"])
|
||||
indices: set[int] = set()
|
||||
for rec in parsed:
|
||||
if str(rec["label"]) != label:
|
||||
raise ValueError("share_label_mismatch")
|
||||
if int(rec["threshold"]) != threshold:
|
||||
raise ValueError("share_threshold_mismatch")
|
||||
if len(rec["values"]) != expected_len:
|
||||
raise ValueError("share_length_mismatch")
|
||||
idx = int(rec["index"])
|
||||
if idx in indices:
|
||||
raise ValueError("duplicate_share_index")
|
||||
indices.add(idx)
|
||||
if len(parsed) < threshold:
|
||||
raise ValueError("insufficient_shares")
|
||||
|
||||
used = parsed[:threshold]
|
||||
xs = [int(v["index"]) % PRIME for v in used]
|
||||
out = bytearray()
|
||||
for byte_idx in range(expected_len):
|
||||
ys = [int(v["values"][byte_idx]) % PRIME for v in used]
|
||||
secret_val = 0
|
||||
for i in range(threshold):
|
||||
num = 1
|
||||
den = 1
|
||||
xi = xs[i]
|
||||
for j in range(threshold):
|
||||
if i == j:
|
||||
continue
|
||||
xj = xs[j]
|
||||
num = (num * (-xj % PRIME)) % PRIME
|
||||
den = (den * (xi - xj)) % PRIME
|
||||
li = (num * _mod_inv(den)) % PRIME
|
||||
secret_val = (secret_val + ys[i] * li) % PRIME
|
||||
if secret_val < 0 or secret_val > 255:
|
||||
raise ValueError("recovered_secret_out_of_range")
|
||||
out.append(secret_val)
|
||||
return out.decode("utf-8")
|
||||
|
||||
|
||||
def _drill_key_path() -> Path:
|
||||
return APP_DIR / "agent_recovery_drill.key"
|
||||
|
||||
|
||||
def _drill_log_path() -> Path:
|
||||
return APP_DIR / "agent_recovery_drills.log"
|
||||
|
||||
|
||||
def _load_drill_key() -> bytes:
|
||||
path = _drill_key_path()
|
||||
if path.exists():
|
||||
return path.read_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
key = os.urandom(32)
|
||||
path.write_bytes(key)
|
||||
os.chmod(path, 0o600)
|
||||
return key
|
||||
|
||||
|
||||
def _latest_drill_sig(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return "0" * 64
|
||||
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return "0" * 64
|
||||
try:
|
||||
payload = json.loads(lines[-1])
|
||||
except Exception:
|
||||
return "0" * 64
|
||||
return str(payload.get("sig", "0" * 64))
|
||||
|
||||
|
||||
def record_recovery_drill(
|
||||
*,
|
||||
fingerprint: str,
|
||||
backup_path: str,
|
||||
simulated: bool,
|
||||
expected_max_age_days: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
path = Path(backup_path).expanduser()
|
||||
exists = path.exists()
|
||||
size = int(path.stat().st_size) if exists else 0
|
||||
modified_ts = float(path.stat().st_mtime) if exists else 0.0
|
||||
age_days: int | None = None
|
||||
if exists:
|
||||
age_days = int((time.time() - modified_ts) // 86400)
|
||||
stale = bool(
|
||||
age_days is not None
|
||||
and expected_max_age_days is not None
|
||||
and age_days > int(expected_max_age_days)
|
||||
)
|
||||
status = "ok" if exists and not stale else "warning"
|
||||
|
||||
record = {
|
||||
"timestamp_utc": _utcnow_iso(),
|
||||
"fingerprint": str(fingerprint),
|
||||
"backup_path": str(path),
|
||||
"backup_exists": bool(exists),
|
||||
"backup_size": size,
|
||||
"backup_age_days": age_days,
|
||||
"expected_max_age_days": (
|
||||
int(expected_max_age_days) if expected_max_age_days is not None else None
|
||||
),
|
||||
"stale": bool(stale),
|
||||
"simulated": bool(simulated),
|
||||
"status": status,
|
||||
}
|
||||
log_path = _drill_log_path()
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prev_sig = _latest_drill_sig(log_path)
|
||||
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
sig = hmac.new(
|
||||
_load_drill_key(),
|
||||
f"{prev_sig}{canonical}".encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
full = dict(record)
|
||||
full["sig"] = sig
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(full, sort_keys=True) + "\n")
|
||||
os.chmod(log_path, 0o600)
|
||||
return full
|
||||
|
||||
|
||||
def list_recovery_drills(*, limit: int = 20) -> list[dict[str, Any]]:
|
||||
path = _drill_log_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
for line in lines[-int(limit) :]:
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
out.append(payload)
|
||||
return out
|
||||
|
||||
|
||||
def verify_recovery_drills(*, limit: int = 200) -> dict[str, Any]:
|
||||
path = _drill_log_path()
|
||||
if not path.exists():
|
||||
return {"valid": True, "checked": 0, "errors": []}
|
||||
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return {"valid": True, "checked": 0, "errors": []}
|
||||
if int(limit) > 0:
|
||||
lines = lines[-int(limit) :]
|
||||
key = _load_drill_key()
|
||||
prev_sig = "0" * 64
|
||||
errors: list[str] = []
|
||||
checked = 0
|
||||
for idx, line in enumerate(lines):
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except Exception:
|
||||
errors.append(f"invalid_json_line:{idx}")
|
||||
continue
|
||||
if not isinstance(rec, dict):
|
||||
errors.append(f"invalid_record_line:{idx}")
|
||||
continue
|
||||
sig = str(rec.get("sig", ""))
|
||||
if not sig:
|
||||
errors.append(f"missing_sig_line:{idx}")
|
||||
continue
|
||||
body = dict(rec)
|
||||
body.pop("sig", None)
|
||||
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
|
||||
expected = hmac.new(
|
||||
key, f"{prev_sig}{canonical}".encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if expected != sig:
|
||||
errors.append(f"sig_mismatch_line:{idx}")
|
||||
prev_sig = sig
|
||||
checked += 1
|
||||
return {"valid": len(errors) == 0, "checked": checked, "errors": errors}
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from constants import APP_DIR
|
||||
from utils.file_lock import exclusive_lock
|
||||
|
||||
ISOLATION_STORE_VERSION = 1
|
||||
PARTITION_ENVELOPE_VERSION = 1
|
||||
PARTITION_KDF_ITERATIONS = 200_000
|
||||
|
||||
|
||||
def _factor_hash_path() -> Path:
|
||||
return APP_DIR / "agent_high_risk_factor.hash"
|
||||
|
||||
|
||||
def _partition_envelope_path() -> Path:
|
||||
return APP_DIR / "agent_high_risk_partition.key.enc.json"
|
||||
|
||||
|
||||
def _session_store_path() -> Path:
|
||||
return APP_DIR / "agent_high_risk_unlock.json"
|
||||
|
||||
|
||||
def _session_lock_path() -> Path:
|
||||
return APP_DIR / "agent_high_risk_unlock.lock"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str) -> datetime:
|
||||
dt = datetime.fromisoformat(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _empty_store() -> dict[str, Any]:
|
||||
return {"version": ISOLATION_STORE_VERSION, "sessions": []}
|
||||
|
||||
|
||||
def _load_session_store_unlocked() -> dict[str, Any]:
|
||||
path = _session_store_path()
|
||||
if not path.exists():
|
||||
return _empty_store()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return _empty_store()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("sessions"), list):
|
||||
return _empty_store()
|
||||
return data
|
||||
|
||||
|
||||
def _save_session_store_unlocked(store: dict[str, Any]) -> None:
|
||||
path = _session_store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _factor_hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _partition_key_tag(partition_key: str) -> str:
|
||||
return hashlib.sha256(partition_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _derive_wrapping_key(factor: str, salt: bytes, iterations: int) -> bytes:
|
||||
raw = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
factor.encode("utf-8"),
|
||||
salt,
|
||||
int(iterations),
|
||||
dklen=32,
|
||||
)
|
||||
return base64.urlsafe_b64encode(raw)
|
||||
|
||||
|
||||
def _build_envelope(partition_key: str, factor: str) -> dict[str, Any]:
|
||||
salt = os.urandom(16)
|
||||
key = _derive_wrapping_key(factor, salt, PARTITION_KDF_ITERATIONS)
|
||||
wrapped = Fernet(key).encrypt(partition_key.encode("utf-8")).decode("utf-8")
|
||||
return {
|
||||
"version": PARTITION_ENVELOPE_VERSION,
|
||||
"kdf": "pbkdf2-sha256",
|
||||
"iterations": PARTITION_KDF_ITERATIONS,
|
||||
"salt_b64": base64.b64encode(salt).decode("ascii"),
|
||||
"wrapped_partition_key": wrapped,
|
||||
}
|
||||
|
||||
|
||||
def _load_partition_envelope() -> dict[str, Any] | None:
|
||||
path = _partition_envelope_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _unwrap_partition_key(envelope: dict[str, Any], factor: str) -> str:
|
||||
if int(envelope.get("version", 0)) != PARTITION_ENVELOPE_VERSION:
|
||||
raise ValueError("unsupported_partition_envelope_version")
|
||||
salt_b64 = str(envelope.get("salt_b64", ""))
|
||||
wrapped = str(envelope.get("wrapped_partition_key", ""))
|
||||
iterations = int(envelope.get("iterations", PARTITION_KDF_ITERATIONS))
|
||||
if not salt_b64 or not wrapped or iterations < 1:
|
||||
raise ValueError("invalid_partition_envelope")
|
||||
salt = base64.b64decode(salt_b64)
|
||||
key = _derive_wrapping_key(factor, salt, iterations)
|
||||
try:
|
||||
return Fernet(key).decrypt(wrapped.encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError) as exc:
|
||||
raise ValueError("high_risk_factor_invalid") from exc
|
||||
|
||||
|
||||
def set_high_risk_factor(factor: str) -> None:
|
||||
app_dir = _factor_hash_path().parent
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Keep legacy hash marker for compatibility while enforcing envelope unwrap.
|
||||
hash_path = _factor_hash_path()
|
||||
hash_path.write_text(_factor_hash(factor), encoding="utf-8")
|
||||
os.chmod(hash_path, 0o600)
|
||||
|
||||
partition_key = Fernet.generate_key().decode("ascii")
|
||||
envelope = _build_envelope(partition_key, factor)
|
||||
envelope_path = _partition_envelope_path()
|
||||
envelope_path.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
|
||||
os.chmod(envelope_path, 0o600)
|
||||
|
||||
|
||||
def high_risk_factor_configured() -> bool:
|
||||
env_path = _partition_envelope_path()
|
||||
if env_path.exists():
|
||||
return True
|
||||
path = _factor_hash_path()
|
||||
return path.exists() and bool(path.read_text(encoding="utf-8").strip())
|
||||
|
||||
|
||||
def verify_high_risk_factor(factor: str) -> bool:
|
||||
envelope = _load_partition_envelope()
|
||||
if envelope is not None:
|
||||
try:
|
||||
_unwrap_partition_key(envelope, factor)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Legacy fallback if envelope has not been initialized yet.
|
||||
path = _factor_hash_path()
|
||||
if not path.exists():
|
||||
return False
|
||||
expected = path.read_text(encoding="utf-8").strip()
|
||||
if not expected:
|
||||
return False
|
||||
return _factor_hash(factor) == expected
|
||||
|
||||
|
||||
def unwrap_high_risk_partition_key(factor: str) -> str:
|
||||
envelope = _load_partition_envelope()
|
||||
if envelope is None:
|
||||
raise ValueError("high_risk_partition_not_configured")
|
||||
return _unwrap_partition_key(envelope, factor)
|
||||
|
||||
|
||||
def grant_high_risk_unlock(
|
||||
*, fingerprint: str, ttl_seconds: int, partition_key_tag: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
now = _utcnow()
|
||||
rec = {
|
||||
"fingerprint": str(fingerprint),
|
||||
"created_at_utc": now.isoformat(),
|
||||
"expires_at_utc": (now + timedelta(seconds=int(ttl_seconds))).isoformat(),
|
||||
"partition_key_tag": str(partition_key_tag or ""),
|
||||
}
|
||||
with exclusive_lock(_session_lock_path()):
|
||||
store = _load_session_store_unlocked()
|
||||
sessions = [
|
||||
s
|
||||
for s in store.get("sessions", [])
|
||||
if str(s.get("fingerprint", "")) != str(fingerprint)
|
||||
]
|
||||
sessions.append(rec)
|
||||
store["sessions"] = sessions
|
||||
_save_session_store_unlocked(store)
|
||||
return rec
|
||||
|
||||
|
||||
def high_risk_unlocked(*, fingerprint: str) -> tuple[bool, str]:
|
||||
now = _utcnow()
|
||||
with exclusive_lock(_session_lock_path()):
|
||||
store = _load_session_store_unlocked()
|
||||
sessions = store.get("sessions", [])
|
||||
kept: list[dict[str, Any]] = []
|
||||
matched: dict[str, Any] | None = None
|
||||
for rec in sessions:
|
||||
expires = str(rec.get("expires_at_utc", ""))
|
||||
try:
|
||||
valid = bool(expires) and _parse_iso(expires) > now
|
||||
except Exception:
|
||||
valid = False
|
||||
if not valid:
|
||||
continue
|
||||
kept.append(rec)
|
||||
if str(rec.get("fingerprint", "")) == str(fingerprint):
|
||||
matched = rec
|
||||
if len(kept) != len(sessions):
|
||||
store["sessions"] = kept
|
||||
_save_session_store_unlocked(store)
|
||||
if matched:
|
||||
return True, str(matched.get("expires_at_utc", ""))
|
||||
return False, ""
|
||||
|
||||
|
||||
def revoke_high_risk_unlock(*, fingerprint: str) -> bool:
|
||||
changed = False
|
||||
with exclusive_lock(_session_lock_path()):
|
||||
store = _load_session_store_unlocked()
|
||||
sessions = store.get("sessions", [])
|
||||
kept = [
|
||||
s for s in sessions if str(s.get("fingerprint", "")) != str(fingerprint)
|
||||
]
|
||||
changed = len(kept) != len(sessions)
|
||||
if changed:
|
||||
store["sessions"] = kept
|
||||
_save_session_store_unlocked(store)
|
||||
return changed
|
||||
|
||||
|
||||
def unlocked_partition_key_tag(*, fingerprint: str) -> str:
|
||||
now = _utcnow()
|
||||
with exclusive_lock(_session_lock_path()):
|
||||
store = _load_session_store_unlocked()
|
||||
sessions = store.get("sessions", [])
|
||||
for rec in sessions:
|
||||
if str(rec.get("fingerprint", "")) != str(fingerprint):
|
||||
continue
|
||||
expires = str(rec.get("expires_at_utc", ""))
|
||||
try:
|
||||
if not expires or _parse_iso(expires) <= now:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
return str(rec.get("partition_key_tag", ""))
|
||||
return ""
|
||||
|
||||
|
||||
def partition_key_tag_for_factor(factor: str) -> str:
|
||||
partition_key = unwrap_high_risk_partition_key(factor)
|
||||
return _partition_key_tag(partition_key)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import APP_DIR
|
||||
from utils.file_lock import exclusive_lock
|
||||
|
||||
LEASE_STORE_VERSION = 1
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return APP_DIR / "agent_secret_leases.json"
|
||||
|
||||
|
||||
def _lock_path() -> Path:
|
||||
return APP_DIR / "agent_secret_leases.lock"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str) -> datetime:
|
||||
dt = datetime.fromisoformat(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _empty_store() -> dict[str, Any]:
|
||||
return {"version": LEASE_STORE_VERSION, "leases": []}
|
||||
|
||||
|
||||
def _load_store_unlocked() -> dict[str, Any]:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return _empty_store()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return _empty_store()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("leases"), list):
|
||||
return _empty_store()
|
||||
return data
|
||||
|
||||
|
||||
def _save_store_unlocked(store: dict[str, Any]) -> None:
|
||||
path = _store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def issue_lease(
|
||||
*,
|
||||
fingerprint: str,
|
||||
index: int,
|
||||
kind: str,
|
||||
label: str,
|
||||
ttl_seconds: int,
|
||||
uses: int,
|
||||
token_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
now = _utcnow()
|
||||
rec = {
|
||||
"id": secrets.token_urlsafe(18),
|
||||
"fingerprint": str(fingerprint),
|
||||
"resource": f"entry:{kind}:{index}",
|
||||
"index": int(index),
|
||||
"kind": str(kind),
|
||||
"label": str(label),
|
||||
"created_at_utc": now.isoformat(),
|
||||
"expires_at_utc": (now + timedelta(seconds=int(ttl_seconds))).isoformat(),
|
||||
"uses_remaining": int(uses),
|
||||
"token_id": token_id,
|
||||
"revoked_at_utc": None,
|
||||
}
|
||||
with exclusive_lock(_lock_path()):
|
||||
store = _load_store_unlocked()
|
||||
store.setdefault("version", LEASE_STORE_VERSION)
|
||||
store.setdefault("leases", [])
|
||||
store["leases"].append(rec)
|
||||
_save_store_unlocked(store)
|
||||
return rec
|
||||
|
||||
|
||||
def list_leases(*, include_revoked: bool = False) -> list[dict[str, Any]]:
|
||||
with exclusive_lock(_lock_path()):
|
||||
leases = _load_store_unlocked().get("leases", [])
|
||||
if include_revoked:
|
||||
return leases
|
||||
return [v for v in leases if not v.get("revoked_at_utc")]
|
||||
|
||||
|
||||
def revoke_lease(lease_id: str) -> bool:
|
||||
changed = False
|
||||
with exclusive_lock(_lock_path()):
|
||||
store = _load_store_unlocked()
|
||||
for rec in store.get("leases", []):
|
||||
if rec.get("id") == lease_id and not rec.get("revoked_at_utc"):
|
||||
rec["revoked_at_utc"] = _utcnow().isoformat()
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
_save_store_unlocked(store)
|
||||
return changed
|
||||
|
||||
|
||||
def consume_lease(
|
||||
*,
|
||||
lease_id: str,
|
||||
fingerprint: str | None = None,
|
||||
) -> tuple[bool, str, dict[str, Any] | None]:
|
||||
with exclusive_lock(_lock_path()):
|
||||
store = _load_store_unlocked()
|
||||
now = _utcnow()
|
||||
for rec in store.get("leases", []):
|
||||
if rec.get("id") != lease_id:
|
||||
continue
|
||||
if rec.get("revoked_at_utc"):
|
||||
return False, "lease_revoked", None
|
||||
if fingerprint and str(rec.get("fingerprint")) != str(fingerprint):
|
||||
return False, "lease_fingerprint_mismatch", None
|
||||
expires = str(rec.get("expires_at_utc", ""))
|
||||
if not expires or _parse_iso(expires) <= now:
|
||||
return False, "lease_expired", None
|
||||
uses = int(rec.get("uses_remaining", 0))
|
||||
if uses <= 0:
|
||||
return False, "lease_exhausted", None
|
||||
rec["uses_remaining"] = uses - 1
|
||||
_save_store_unlocked(store)
|
||||
return True, "lease_consumed", dict(rec)
|
||||
return False, "lease_not_found", None
|
||||
@@ -11,6 +11,7 @@ from utils.seed_prompt import masked_input
|
||||
import bcrypt
|
||||
|
||||
from .vault import Vault
|
||||
from .entry_types import EntryType
|
||||
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
|
||||
|
||||
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
|
||||
@@ -28,6 +29,29 @@ class ConfigManager:
|
||||
DEFAULT_PBKDF2_ITERATIONS = 200_000
|
||||
LEGACY_PBKDF2_ITERATION_FALLBACKS = (50_000, 100_000)
|
||||
|
||||
SECRET_CLASS_PARTITIONS_DEFAULT = {
|
||||
"standard": {
|
||||
"kinds": [
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
],
|
||||
"unlocked": True,
|
||||
"separate_factor_required": False,
|
||||
},
|
||||
"high_risk": {
|
||||
"kinds": [
|
||||
EntryType.SEED.value,
|
||||
EntryType.SSH.value,
|
||||
EntryType.PGP.value,
|
||||
EntryType.NOSTR.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
],
|
||||
"unlocked": False,
|
||||
"separate_factor_required": True,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, vault: Vault, fingerprint_dir: Path):
|
||||
self.vault = vault
|
||||
self.fingerprint_dir = fingerprint_dir
|
||||
@@ -69,6 +93,7 @@ class ConfigManager:
|
||||
"special_mode": "standard",
|
||||
"exclude_ambiguous": False,
|
||||
"verbose_timing": False,
|
||||
"secret_class_partitions": dict(self.SECRET_CLASS_PARTITIONS_DEFAULT),
|
||||
}
|
||||
try:
|
||||
data = self.vault.load_config()
|
||||
@@ -99,6 +124,10 @@ class ConfigManager:
|
||||
data.setdefault("special_mode", "standard")
|
||||
data.setdefault("exclude_ambiguous", False)
|
||||
data.setdefault("verbose_timing", False)
|
||||
data.setdefault(
|
||||
"secret_class_partitions",
|
||||
dict(self.SECRET_CLASS_PARTITIONS_DEFAULT),
|
||||
)
|
||||
|
||||
# Migrate legacy hashed_password.enc if present and password_hash is missing
|
||||
legacy_file = self.fingerprint_dir / "hashed_password.enc"
|
||||
@@ -382,3 +411,41 @@ class ConfigManager:
|
||||
def get_verbose_timing(self) -> bool:
|
||||
cfg = self.load_config(require_pin=False)
|
||||
return bool(cfg.get("verbose_timing", False))
|
||||
|
||||
def get_secret_class_partitions(self) -> dict:
|
||||
"""Return partition metadata for secret classes."""
|
||||
cfg = self.load_config(require_pin=False)
|
||||
value = cfg.get("secret_class_partitions")
|
||||
if not isinstance(value, dict):
|
||||
value = dict(self.SECRET_CLASS_PARTITIONS_DEFAULT)
|
||||
return value
|
||||
|
||||
def set_secret_class_partitions(self, partitions: dict) -> None:
|
||||
"""Persist partition metadata for secret classes."""
|
||||
if not isinstance(partitions, dict):
|
||||
raise ValueError("partitions must be a dictionary")
|
||||
cfg = self.load_config(require_pin=False)
|
||||
cfg["secret_class_partitions"] = partitions
|
||||
self.save_config(cfg)
|
||||
|
||||
def set_partition_unlock_state(self, partition: str, unlocked: bool) -> None:
|
||||
"""Persist unlock state for one partition."""
|
||||
cfg = self.load_config(require_pin=False)
|
||||
partitions = cfg.get("secret_class_partitions")
|
||||
if not isinstance(partitions, dict):
|
||||
partitions = dict(self.SECRET_CLASS_PARTITIONS_DEFAULT)
|
||||
p = partitions.get(partition)
|
||||
if not isinstance(p, dict):
|
||||
p = {"kinds": [], "separate_factor_required": False}
|
||||
p["unlocked"] = bool(unlocked)
|
||||
partitions[partition] = p
|
||||
cfg["secret_class_partitions"] = partitions
|
||||
self.save_config(cfg)
|
||||
|
||||
def is_partition_unlocked(self, partition: str) -> bool:
|
||||
"""Return unlock state for one partition."""
|
||||
partitions = self.get_secret_class_partitions()
|
||||
p = partitions.get(partition)
|
||||
if not isinstance(p, dict):
|
||||
return False
|
||||
return bool(p.get("unlocked", False))
|
||||
@@ -29,6 +29,7 @@ from mnemonic import Mnemonic
|
||||
from utils.password_prompt import prompt_existing_password
|
||||
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
|
||||
from .errors import DecryptionError
|
||||
from .sync_conflict import merge_index_payloads
|
||||
|
||||
# Instantiate the logger
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -474,18 +475,8 @@ class EncryptionManager:
|
||||
existing_file = self.resolve_relative_path(relative_path)
|
||||
if merge and existing_file.exists():
|
||||
current = self.load_json_data(relative_path)
|
||||
current_entries = current.get("entries", {})
|
||||
for idx, entry in data.get("entries", {}).items():
|
||||
cur_ts = current_entries.get(idx, {}).get("modified_ts", 0)
|
||||
new_ts = entry.get("modified_ts", 0)
|
||||
if idx not in current_entries or new_ts >= cur_ts:
|
||||
current_entries[idx] = entry
|
||||
current["entries"] = current_entries
|
||||
if "schema_version" in data:
|
||||
current["schema_version"] = max(
|
||||
current.get("schema_version", 0), data.get("schema_version", 0)
|
||||
)
|
||||
data = current
|
||||
source_tag = hashlib.sha256(encrypted_data).hexdigest()[:16]
|
||||
data = merge_index_payloads(current, data, source_tag=source_tag)
|
||||
return data
|
||||
|
||||
try:
|
||||
|
||||
@@ -1313,6 +1313,41 @@ class EntryManager:
|
||||
try:
|
||||
data = self._load_index()
|
||||
if "entries" in data and str(index) in data["entries"]:
|
||||
entry = data["entries"][str(index)]
|
||||
deleted_ts = int(time.time())
|
||||
entry_hash = hashlib.sha256(
|
||||
canonical_json_dumps(entry).encode("utf-8")
|
||||
).hexdigest()
|
||||
tombstone_payload = {
|
||||
"kind": "tombstone",
|
||||
"index": str(index),
|
||||
"deleted_ts": deleted_ts,
|
||||
"entry_hash": entry_hash,
|
||||
}
|
||||
event_hash = hashlib.sha256(
|
||||
canonical_json_dumps(tombstone_payload).encode("utf-8")
|
||||
).hexdigest()
|
||||
sync_meta = data.setdefault("_sync_meta", {})
|
||||
if not isinstance(sync_meta, dict):
|
||||
sync_meta = {}
|
||||
data["_sync_meta"] = sync_meta
|
||||
tombstones = sync_meta.setdefault("tombstones", {})
|
||||
if not isinstance(tombstones, dict):
|
||||
tombstones = {}
|
||||
sync_meta["tombstones"] = tombstones
|
||||
current = tombstones.get(str(index), {})
|
||||
current_ts = (
|
||||
int(current.get("deleted_ts", 0))
|
||||
if isinstance(current, dict)
|
||||
else 0
|
||||
)
|
||||
if deleted_ts >= current_ts:
|
||||
tombstones[str(index)] = {
|
||||
"deleted_ts": deleted_ts,
|
||||
"entry_hash": entry_hash,
|
||||
"event_hash": event_hash,
|
||||
"source": "local-delete",
|
||||
}
|
||||
del data["entries"][str(index)]
|
||||
logger.debug(f"Deleted entry at index {index}.")
|
||||
self._save_index(data)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
PARTITION_FILENAME = "seedpass_high_risk_entries.json.enc"
|
||||
|
||||
|
||||
def _partition_path(fingerprint_dir: Path) -> Path:
|
||||
return Path(fingerprint_dir) / PARTITION_FILENAME
|
||||
|
||||
|
||||
def _fernet_for_tag(partition_key_tag: str) -> Fernet:
|
||||
raw = hashlib.sha256(partition_key_tag.encode("utf-8")).digest()
|
||||
key = base64.urlsafe_b64encode(raw)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def load_partition_entries(
|
||||
fingerprint_dir: Path, partition_key_tag: str
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
path = _partition_path(fingerprint_dir)
|
||||
if not path.exists():
|
||||
return {}
|
||||
fernet = _fernet_for_tag(partition_key_tag)
|
||||
blob = path.read_bytes()
|
||||
try:
|
||||
payload = fernet.decrypt(blob).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
raise ValueError("invalid_partition_key_tag") from exc
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
entries = data.get("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
return {}
|
||||
return {str(k): v for k, v in entries.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def save_partition_entries(
|
||||
fingerprint_dir: Path,
|
||||
partition_key_tag: str,
|
||||
entries: dict[str, dict[str, Any]],
|
||||
) -> Path:
|
||||
path = _partition_path(fingerprint_dir)
|
||||
fernet = _fernet_for_tag(partition_key_tag)
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"partition": "high_risk",
|
||||
"updated_at_utc": time.time(),
|
||||
"entries": entries,
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(fernet.encrypt(raw))
|
||||
return path
|
||||
|
||||
|
||||
def load_partition_entry(
|
||||
fingerprint_dir: Path, partition_key_tag: str, index: int
|
||||
) -> dict[str, Any] | None:
|
||||
entries = load_partition_entries(fingerprint_dir, partition_key_tag)
|
||||
return entries.get(str(index))
|
||||
|
||||
|
||||
def migrate_high_risk_entries(
|
||||
*,
|
||||
vault: Any,
|
||||
fingerprint_dir: Path,
|
||||
partition_key_tag: str,
|
||||
high_risk_kinds: set[str],
|
||||
) -> dict[str, Any]:
|
||||
index = vault.load_index()
|
||||
entries = index.get("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
entries = {}
|
||||
partition_entries = load_partition_entries(fingerprint_dir, partition_key_tag)
|
||||
|
||||
moved_indexes: list[str] = []
|
||||
for idx, entry in list(entries.items()):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
kind = str(entry.get("kind", entry.get("type", ""))).strip().lower()
|
||||
if kind not in high_risk_kinds:
|
||||
continue
|
||||
idx_s = str(idx)
|
||||
partition_entries[idx_s] = entry
|
||||
entries[idx_s] = {
|
||||
"type": kind,
|
||||
"kind": kind,
|
||||
"index": int(entry.get("index", idx)),
|
||||
"label": str(entry.get("label", "")),
|
||||
"archived": bool(entry.get("archived", False)),
|
||||
"partition": "high_risk",
|
||||
"partition_ref": idx_s,
|
||||
"modified_ts": int(entry.get("modified_ts", int(time.time()))),
|
||||
}
|
||||
moved_indexes.append(idx_s)
|
||||
|
||||
index["entries"] = entries
|
||||
if moved_indexes:
|
||||
vault.save_index(index)
|
||||
save_partition_entries(fingerprint_dir, partition_key_tag, partition_entries)
|
||||
|
||||
return {
|
||||
"moved_count": len(moved_indexes),
|
||||
"moved_indexes": sorted(moved_indexes, key=lambda v: int(v)),
|
||||
"partition_file": str(_partition_path(fingerprint_dir)),
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
TOMBSTONE_RETENTION_CAP = 2048
|
||||
|
||||
|
||||
def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _entry_hash(entry: dict[str, Any]) -> str:
|
||||
return hashlib.sha256(_canonical(entry).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _entry_ts(entry: dict[str, Any]) -> int:
|
||||
return _safe_int(entry.get("modified_ts", 0), default=0)
|
||||
|
||||
|
||||
def _safe_int(raw: Any, *, default: int = 0) -> int:
|
||||
try:
|
||||
return int(raw)
|
||||
except Exception:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _is_empty(value: Any) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return value.strip() == ""
|
||||
return False
|
||||
|
||||
|
||||
def _entry_kind(entry: dict[str, Any]) -> str:
|
||||
return str(entry.get("kind", entry.get("type", "password"))).strip().lower()
|
||||
|
||||
|
||||
def _is_deleted_entry(entry: dict[str, Any]) -> bool:
|
||||
return bool(entry.get("_deleted", False) or entry.get("deleted", False))
|
||||
|
||||
|
||||
def _entry_event_hash(entry: dict[str, Any]) -> str:
|
||||
marker = {"entry": entry, "kind": "entry"}
|
||||
return hashlib.sha256(_canonical(marker).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _tombstone_event_hash(record: dict[str, Any], idx: str) -> str:
|
||||
provided = str(record.get("event_hash", "")).strip().lower()
|
||||
if provided:
|
||||
return provided
|
||||
marker = {
|
||||
"kind": "tombstone",
|
||||
"index": str(idx),
|
||||
"deleted_ts": _safe_int(record.get("deleted_ts", 0), default=0),
|
||||
"entry_hash": str(record.get("entry_hash", "")),
|
||||
}
|
||||
return hashlib.sha256(_canonical(marker).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _prefer_tombstone(
|
||||
current: dict[str, Any], incoming: dict[str, Any], idx: str
|
||||
) -> bool:
|
||||
cur_ts = _safe_int(current.get("deleted_ts", 0), default=0)
|
||||
inc_ts = _safe_int(incoming.get("deleted_ts", 0), default=0)
|
||||
if inc_ts != cur_ts:
|
||||
return inc_ts > cur_ts
|
||||
return _tombstone_event_hash(incoming, idx) > _tombstone_event_hash(current, idx)
|
||||
|
||||
|
||||
def _normalize_tombstones(value: Any) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
for k, v in value.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
idx = str(k)
|
||||
ts_raw = v.get("deleted_ts", 0)
|
||||
deleted_ts = _safe_int(ts_raw, default=0)
|
||||
if deleted_ts <= 0:
|
||||
continue
|
||||
out[idx] = {
|
||||
"deleted_ts": deleted_ts,
|
||||
"entry_hash": str(v.get("entry_hash", "")),
|
||||
"event_hash": str(v.get("event_hash", "")),
|
||||
"source": str(v.get("source", "")),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _merge_tombstones(
|
||||
current: dict[str, dict[str, Any]], incoming: dict[str, dict[str, Any]]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
merged = dict(current)
|
||||
for idx, rec in incoming.items():
|
||||
cur = merged.get(idx)
|
||||
if cur is None or _prefer_tombstone(cur, rec, idx):
|
||||
merged[idx] = dict(rec)
|
||||
return merged
|
||||
|
||||
|
||||
def _max_entry_ts(entries: dict[str, Any]) -> int:
|
||||
max_ts = 0
|
||||
for value in entries.values():
|
||||
if isinstance(value, dict):
|
||||
ts = _entry_ts(value)
|
||||
if ts > max_ts:
|
||||
max_ts = ts
|
||||
return max_ts
|
||||
|
||||
|
||||
def _max_tombstone_ts(tombstones: dict[str, dict[str, Any]]) -> int:
|
||||
max_ts = 0
|
||||
for record in tombstones.values():
|
||||
if isinstance(record, dict):
|
||||
ts = _safe_int(record.get("deleted_ts", 0), default=0)
|
||||
if ts > max_ts:
|
||||
max_ts = ts
|
||||
return max_ts
|
||||
|
||||
|
||||
def _prefer_entry(current: dict[str, Any], incoming: dict[str, Any]) -> bool:
|
||||
"""Return True when incoming should replace current deterministically.
|
||||
|
||||
Order-independent rule:
|
||||
1) higher modified_ts wins
|
||||
2) on equal ts, lexicographically larger canonical entry hash wins
|
||||
"""
|
||||
cur_ts = _entry_ts(current)
|
||||
inc_ts = _entry_ts(incoming)
|
||||
if inc_ts != cur_ts:
|
||||
return inc_ts > cur_ts
|
||||
return _entry_hash(incoming) > _entry_hash(current)
|
||||
|
||||
|
||||
def _union_fields_for_kind(kind: str) -> list[str]:
|
||||
common = ["notes", "tags", "custom_fields"]
|
||||
matrix = {
|
||||
"password": ["username", "url"],
|
||||
"totp": ["issuer"],
|
||||
"key_value": ["key", "value"],
|
||||
"managed_account": ["value", "user_id"],
|
||||
"ssh": ["username", "public_key", "algorithm", "key_type"],
|
||||
"pgp": ["public_key", "key_type"],
|
||||
"nostr": ["npub", "public_key"],
|
||||
"seed": ["path", "network", "coin_type"],
|
||||
}
|
||||
return matrix.get(kind, []) + common
|
||||
|
||||
|
||||
def _merge_list_union(preferred: Any, other: Any) -> Any:
|
||||
if isinstance(preferred, list) and isinstance(other, list):
|
||||
seen: dict[str, Any] = {}
|
||||
for item in preferred + other:
|
||||
key = _canonical(item)
|
||||
seen[key] = item
|
||||
return [seen[k] for k in sorted(seen.keys())]
|
||||
return preferred
|
||||
|
||||
|
||||
def _merge_equal_ts_entries(
|
||||
preferred: dict[str, Any], other: dict[str, Any], *, ts: int
|
||||
) -> dict[str, Any]:
|
||||
"""Merge entries at equal timestamp with deterministic field-level rules."""
|
||||
merged = dict(preferred)
|
||||
union_fields = _union_fields_for_kind(_entry_kind(merged))
|
||||
for field in union_fields:
|
||||
pv = merged.get(field)
|
||||
ov = other.get(field)
|
||||
if field in ("tags", "custom_fields"):
|
||||
merged[field] = _merge_list_union(pv, ov)
|
||||
continue
|
||||
if _is_empty(pv) and not _is_empty(ov):
|
||||
merged[field] = ov
|
||||
|
||||
# Prefer conservative archival behavior at equal timestamp.
|
||||
if "archived" in preferred or "archived" in other:
|
||||
merged["archived"] = bool(preferred.get("archived", False)) or bool(
|
||||
other.get("archived", False)
|
||||
)
|
||||
merged["modified_ts"] = int(ts)
|
||||
return merged
|
||||
|
||||
|
||||
def merge_index_payloads(
|
||||
current: dict[str, Any], incoming: dict[str, Any], *, source_tag: str = ""
|
||||
) -> dict[str, Any]:
|
||||
"""Merge two index payloads using deterministic conflict resolution."""
|
||||
out = dict(current) if isinstance(current, dict) else {}
|
||||
cur_entries = out.get("entries", {})
|
||||
if not isinstance(cur_entries, dict):
|
||||
cur_entries = {}
|
||||
inc_entries = incoming.get("entries", {}) if isinstance(incoming, dict) else {}
|
||||
if not isinstance(inc_entries, dict):
|
||||
inc_entries = {}
|
||||
cur_meta = (
|
||||
out.get("_sync_meta", {}) if isinstance(out.get("_sync_meta"), dict) else {}
|
||||
)
|
||||
inc_meta = (
|
||||
incoming.get("_sync_meta", {})
|
||||
if isinstance(incoming, dict) and isinstance(incoming.get("_sync_meta"), dict)
|
||||
else {}
|
||||
)
|
||||
cur_tombstones = _normalize_tombstones(cur_meta.get("tombstones", {}))
|
||||
inc_tombstones = _normalize_tombstones(inc_meta.get("tombstones", {}))
|
||||
tombstones = _merge_tombstones(cur_tombstones, inc_tombstones)
|
||||
|
||||
for idx, inc_entry in inc_entries.items():
|
||||
if not isinstance(inc_entry, dict):
|
||||
continue
|
||||
key = str(idx)
|
||||
if _is_deleted_entry(inc_entry):
|
||||
delete_ts = _entry_ts(inc_entry)
|
||||
if delete_ts <= 0:
|
||||
delete_ts = max(
|
||||
_safe_int(cur_meta.get("last_merge_ts", 0), default=0),
|
||||
_safe_int(inc_meta.get("last_merge_ts", 0), default=0),
|
||||
_max_entry_ts(cur_entries),
|
||||
_max_entry_ts(inc_entries),
|
||||
_max_tombstone_ts(tombstones),
|
||||
)
|
||||
if delete_ts <= 0:
|
||||
delete_ts = 1
|
||||
tombstones = _merge_tombstones(
|
||||
tombstones,
|
||||
{
|
||||
key: {
|
||||
"deleted_ts": delete_ts,
|
||||
"entry_hash": str(inc_entry.get("entry_hash", "")),
|
||||
"event_hash": _entry_event_hash(inc_entry),
|
||||
"source": source_tag,
|
||||
}
|
||||
},
|
||||
)
|
||||
cur_entries.pop(key, None)
|
||||
continue
|
||||
cur_entry = cur_entries.get(key)
|
||||
if not isinstance(cur_entry, dict):
|
||||
cur_entries[key] = inc_entry
|
||||
continue
|
||||
cur_ts = _entry_ts(cur_entry)
|
||||
inc_ts = _entry_ts(inc_entry)
|
||||
if cur_ts == inc_ts:
|
||||
if _prefer_entry(cur_entry, inc_entry):
|
||||
cur_entries[key] = _merge_equal_ts_entries(
|
||||
inc_entry, cur_entry, ts=inc_ts
|
||||
)
|
||||
else:
|
||||
cur_entries[key] = _merge_equal_ts_entries(
|
||||
cur_entry, inc_entry, ts=cur_ts
|
||||
)
|
||||
continue
|
||||
if _prefer_entry(cur_entry, inc_entry):
|
||||
cur_entries[key] = inc_entry
|
||||
|
||||
# Apply tombstones deterministically after entry merge.
|
||||
for idx, rec in list(tombstones.items()):
|
||||
entry = cur_entries.get(idx)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
e_ts = _entry_ts(entry)
|
||||
d_ts = _safe_int(rec.get("deleted_ts", 0), default=0)
|
||||
if e_ts > d_ts:
|
||||
tombstones.pop(idx, None)
|
||||
continue
|
||||
if e_ts < d_ts:
|
||||
cur_entries.pop(idx, None)
|
||||
continue
|
||||
# Equal timestamp: compare event hashes to break ties.
|
||||
if _tombstone_event_hash(rec, idx) > _entry_event_hash(entry):
|
||||
cur_entries.pop(idx, None)
|
||||
else:
|
||||
tombstones.pop(idx, None)
|
||||
|
||||
out["entries"] = cur_entries
|
||||
if isinstance(incoming, dict) and "schema_version" in incoming:
|
||||
out["schema_version"] = max(
|
||||
int(out.get("schema_version", 0)), int(incoming.get("schema_version", 0))
|
||||
)
|
||||
|
||||
meta = out.get("_sync_meta", {})
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
sources = meta.get("sources", [])
|
||||
if not isinstance(sources, list):
|
||||
sources = []
|
||||
if source_tag and source_tag not in sources:
|
||||
sources.append(source_tag)
|
||||
sources = sorted(set(str(v) for v in sources if str(v)))
|
||||
if tombstones:
|
||||
tomb_items = sorted(
|
||||
tombstones.items(),
|
||||
key=lambda item: (
|
||||
_safe_int(item[1].get("deleted_ts", 0), default=0),
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
if len(tomb_items) > TOMBSTONE_RETENTION_CAP:
|
||||
tomb_items = tomb_items[-TOMBSTONE_RETENTION_CAP:]
|
||||
tombstones = {k: v for k, v in tomb_items}
|
||||
last_merge_ts = max(
|
||||
_safe_int(meta.get("last_merge_ts", 0), default=0),
|
||||
_safe_int(inc_meta.get("last_merge_ts", 0), default=0),
|
||||
_max_entry_ts(cur_entries),
|
||||
_max_tombstone_ts(tombstones),
|
||||
)
|
||||
meta.update(
|
||||
{
|
||||
"strategy": "modified_ts_hash_tombstone_v2",
|
||||
"last_merge_ts": last_merge_ts,
|
||||
"source_count": len(sources),
|
||||
"sources": sources[-32:],
|
||||
"tombstones": tombstones,
|
||||
}
|
||||
)
|
||||
out["_sync_meta"] = meta
|
||||
return out
|
||||
@@ -2,11 +2,18 @@ from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from seedpass import api
|
||||
import seedpass.core.agent_export_policy as export_policy
|
||||
import seedpass.core.agent_approval as approval_core
|
||||
import seedpass.core.agent_secret_isolation as isolation_core
|
||||
import seedpass.core.agent_secret_lease as lease_core
|
||||
import seedpass.core.agent_job as job_core
|
||||
import seedpass.core.agent_recovery as recovery_core
|
||||
import string
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from nostr.client import NostrClient, DEFAULT_RELAYS
|
||||
@@ -116,6 +123,80 @@ async def test_update_entry_error(client):
|
||||
assert res.json() == {"detail": "nope"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_entry_private_kind_blocked_when_high_risk_locked(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path)
|
||||
isolation_core.set_high_risk_factor("factor-get")
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps({"secret_isolation": {"enabled": True, "high_risk_kinds": ["ssh"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
api.app.state.pm.entry_manager.retrieve_entry = lambda _i: {
|
||||
"kind": "ssh",
|
||||
"label": "SSH key",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
}
|
||||
blocked = await cl.get("/api/v1/entry/1", headers=headers)
|
||||
assert blocked.status_code == 403
|
||||
assert blocked.json()["detail"] == "policy_deny:high_risk_locked"
|
||||
|
||||
unlock_headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-High-Risk-Factor": "factor-get",
|
||||
}
|
||||
unlocked = await cl.post(
|
||||
"/api/v1/high-risk/unlock", json={"ttl": 120}, headers=unlock_headers
|
||||
)
|
||||
assert unlocked.status_code == 200
|
||||
|
||||
allowed = await cl.get("/api/v1/entry/1", headers=headers)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.json()["kind"] == "ssh"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_entry_hydrates_partitioned_high_risk_entry(client, monkeypatch):
|
||||
cl, token = client
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
api.app.state.pm.fingerprint_dir = "/tmp/seedpass-tests/fp"
|
||||
api.app.state.pm.entry_manager.retrieve_entry = lambda _i: {
|
||||
"kind": "ssh",
|
||||
"label": "SSH key",
|
||||
"partition": "high_risk",
|
||||
}
|
||||
monkeypatch.setattr(api, "high_risk_factor_configured", lambda: False)
|
||||
monkeypatch.setattr(api, "unlocked_partition_key_tag", lambda **kwargs: "tag-123")
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"load_partition_entry",
|
||||
lambda fingerprint_dir, partition_key_tag, index: {
|
||||
"kind": "ssh",
|
||||
"label": "SSH key",
|
||||
"notes": "from-partition",
|
||||
},
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
}
|
||||
res = await cl.get("/api/v1/entry/1", headers=headers)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["kind"] == "ssh"
|
||||
assert payload["notes"] == "from-partition"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_config_secret_mode(client):
|
||||
cl, token = client
|
||||
@@ -617,7 +698,13 @@ async def test_vault_export_endpoint_denied_for_agent_profile(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
called = {}
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"record_export_policy_event",
|
||||
lambda event, details: called.setdefault("event", (event, details)),
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
@@ -625,7 +712,139 @@ async def test_vault_export_endpoint_denied_for_agent_profile(
|
||||
}
|
||||
res = await cl.post("/api/v1/vault/export", headers=headers)
|
||||
assert res.status_code == 403
|
||||
assert "Policy denied full vault export" in res.json()["detail"]
|
||||
assert res.json()["detail"] == "policy_deny:full_export_blocked"
|
||||
assert called["event"][0] == "export_denied"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_export_endpoint_policy_filtered_manifest(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
'{"allow_kinds":["password"],"allow_export_import":false}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
api.app.state.pm.vault = SimpleNamespace(
|
||||
load_index=lambda: {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"0": {"kind": "password", "label": "ok"},
|
||||
"1": {"kind": "totp", "label": "skip"},
|
||||
},
|
||||
},
|
||||
encryption_manager=SimpleNamespace(encrypt_data=lambda payload: payload),
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
"X-SeedPass-Policy-Filtered": "true",
|
||||
}
|
||||
res = await cl.post("/api/v1/vault/export", headers=headers)
|
||||
assert res.status_code == 200
|
||||
payload = json.loads(res.content.decode("utf-8"))
|
||||
assert payload["_export_manifest"]["mode"] == "policy_filtered"
|
||||
assert payload["_export_manifest"]["allow_kinds"] == ["password"]
|
||||
assert payload["_export_manifest"]["included_entry_indexes"] == ["0"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_export_endpoint_requires_approval_when_configured(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"allow_kinds": ["password"],
|
||||
"allow_export_import": True,
|
||||
"export": {"allow_full_vault": True},
|
||||
"approvals": {"require_for": ["export"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
}
|
||||
res = await cl.post("/api/v1/vault/export", headers=headers)
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "policy_deny:approval_required"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_export_endpoint_blocked_when_high_risk_locked(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path)
|
||||
isolation_core.set_high_risk_factor("factor-abc")
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"allow_export_import": True,
|
||||
"export": {"allow_full_vault": True},
|
||||
"approvals": {"require_for": []},
|
||||
"secret_isolation": {"enabled": True, "high_risk_kinds": ["seed"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
}
|
||||
res = await cl.post("/api/v1/vault/export", headers=headers)
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "policy_deny:high_risk_locked"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_export_endpoint_approval_allows_full_export(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
out = tmp_path / "out.json"
|
||||
out.write_text("data")
|
||||
api.app.state.pm.handle_export_database = lambda *a, **k: out
|
||||
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"allow_kinds": ["password"],
|
||||
"allow_export_import": True,
|
||||
"export": {"allow_full_vault": True},
|
||||
"approvals": {"require_for": ["export"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
approval = approval_core.issue_approval(
|
||||
action="export",
|
||||
ttl_seconds=300,
|
||||
uses=1,
|
||||
resource="vault:full",
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
"X-SeedPass-Approval-Id": approval["id"],
|
||||
}
|
||||
res = await cl.post("/api/v1/vault/export", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.content == b"data"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -645,7 +864,110 @@ async def test_totp_export_endpoint_denied_for_agent_profile(
|
||||
}
|
||||
res = await cl.get("/api/v1/totp/export", headers=headers)
|
||||
assert res.status_code == 403
|
||||
assert "Policy denied TOTP export" in res.json()["detail"]
|
||||
assert res.json()["detail"] == "policy_deny:kind_not_allowed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_export_check_endpoint_full_denied_default(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.get("/api/v1/export/check", params={"mode": "full"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["allowed"] is False
|
||||
assert payload["reason"] == "policy_deny:full_export_blocked"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_export_check_endpoint_kind_allowed_default(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.get(
|
||||
"/api/v1/export/check",
|
||||
params={"mode": "kind", "kind": "totp"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["allowed"] is True
|
||||
assert payload["reason"] == "policy_allow:kind_allowed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_export_check_endpoint_kind_requires_kind(client):
|
||||
cl, token = client
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.get("/api/v1/export/check", params={"mode": "kind"}, headers=headers)
|
||||
assert res.status_code == 400
|
||||
assert res.json()["detail"] == "missing_kind"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_export_manifest_verify_endpoint_ok(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
policy = export_policy.load_export_policy()
|
||||
package = export_policy.build_policy_filtered_export_package(
|
||||
{"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}},
|
||||
policy,
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.post("/api/v1/export/manifest/verify", headers=headers, json=package)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["valid"] is True
|
||||
assert payload["errors"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_export_manifest_verify_endpoint_mismatch(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
package = export_policy.build_policy_filtered_export_package(
|
||||
{"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}},
|
||||
export_policy.load_export_policy(),
|
||||
)
|
||||
# Change policy after package creation to force mismatch.
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
'{"allow_kinds":["totp"],"allow_export_import":false}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.post("/api/v1/export/manifest/verify", headers=headers, json=package)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["valid"] is False
|
||||
assert "policy_stamp_mismatch" in payload["errors"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_export_manifest_verify_endpoint_detects_tamper(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
policy = export_policy.load_export_policy()
|
||||
package = export_policy.build_policy_filtered_export_package(
|
||||
{"schema_version": 4, "entries": {"0": {"kind": "password", "label": "ok"}}},
|
||||
policy,
|
||||
)
|
||||
package["entries"]["0"]["kind"] = "ssh"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.post("/api/v1/export/manifest/verify", headers=headers, json=package)
|
||||
assert res.status_code == 200
|
||||
payload = res.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["valid"] is False
|
||||
assert "entry_kind_not_allowed" in payload["errors"]
|
||||
assert "entries_hash_mismatch" in payload["errors"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -679,6 +1001,446 @@ async def test_backup_parent_seed_endpoint(client, tmp_path):
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_high_risk_api_unlock_status_and_lock(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path)
|
||||
isolation_core.set_high_risk_factor("factor-123")
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
status_before = await cl.get("/api/v1/high-risk/status", headers=headers)
|
||||
assert status_before.status_code == 200
|
||||
before_payload = status_before.json()
|
||||
assert before_payload["status"] == "ok"
|
||||
assert before_payload["unlocked"] is False
|
||||
|
||||
unlock_headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-High-Risk-Factor": "factor-123",
|
||||
}
|
||||
unlocked = await cl.post(
|
||||
"/api/v1/high-risk/unlock",
|
||||
json={"ttl": 120},
|
||||
headers=unlock_headers,
|
||||
)
|
||||
assert unlocked.status_code == 200
|
||||
unlocked_payload = unlocked.json()
|
||||
assert unlocked_payload["status"] == "ok"
|
||||
assert unlocked_payload["fingerprint"] == "ABC123"
|
||||
|
||||
status_after = await cl.get("/api/v1/high-risk/status", headers=headers)
|
||||
assert status_after.status_code == 200
|
||||
assert status_after.json()["unlocked"] is True
|
||||
|
||||
locked = await cl.post("/api/v1/high-risk/lock", headers=headers)
|
||||
assert locked.status_code == 200
|
||||
assert locked.json()["status"] == "ok"
|
||||
|
||||
status_final = await cl.get("/api/v1/high-risk/status", headers=headers)
|
||||
assert status_final.status_code == 200
|
||||
assert status_final.json()["unlocked"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_job_profiles_crud_endpoints(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(job_core, "APP_DIR", tmp_path)
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
created = await cl.post(
|
||||
"/api/v1/agent/job-profiles",
|
||||
headers=headers,
|
||||
json={"id": "nightly", "query": "Site", "auth_broker": "keyring"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
created_payload = created.json()
|
||||
assert created_payload["status"] == "ok"
|
||||
assert created_payload["job_profile"]["id"] == "nightly"
|
||||
|
||||
listed = await cl.get("/api/v1/agent/job-profiles", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
listed_payload = listed.json()
|
||||
assert any(v["id"] == "nightly" for v in listed_payload["job_profiles"])
|
||||
|
||||
revoked = await cl.delete("/api/v1/agent/job-profiles/nightly", headers=headers)
|
||||
assert revoked.status_code == 200
|
||||
assert revoked.json()["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_job_profile_run_issues_lease(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(job_core, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(lease_core, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
api.app.state.pm.entry_manager.search_entries = lambda q: [
|
||||
(7, "Site", "user", "url", False, EntryType.PASSWORD)
|
||||
]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
created = await cl.post(
|
||||
"/api/v1/agent/job-profiles",
|
||||
headers=headers,
|
||||
json={"id": "nightly-run", "query": "Site", "auth_broker": "keyring"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
run = await cl.post(
|
||||
"/api/v1/agent/job-profiles/nightly-run/run",
|
||||
headers=headers,
|
||||
json={},
|
||||
)
|
||||
assert run.status_code == 200
|
||||
payload = run.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["mode"] == "lease_issued"
|
||||
assert payload["kind"] == "password"
|
||||
assert payload["lease_id"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_job_profile_run_policy_mismatch_denied(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(job_core, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(lease_core, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
api.app.state.pm.entry_manager.search_entries = lambda q: [
|
||||
(7, "Site", "user", "url", False, EntryType.PASSWORD)
|
||||
]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
created = await cl.post(
|
||||
"/api/v1/agent/job-profiles",
|
||||
headers=headers,
|
||||
json={"id": "policy-run", "query": "Site", "auth_broker": "keyring"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps({"allow_kinds": ["totp"], "allow_export_import": False}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
denied = await cl.post(
|
||||
"/api/v1/agent/job-profiles/policy-run/run",
|
||||
headers=headers,
|
||||
json={},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
assert denied.json()["detail"] == "job_profile_policy_mismatch"
|
||||
|
||||
allowed = await cl.post(
|
||||
"/api/v1/agent/job-profiles/policy-run/run",
|
||||
headers=headers,
|
||||
json={"allow_policy_drift": True},
|
||||
)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.json()["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_job_profile_template_endpoint(client, tmp_path, monkeypatch):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(job_core, "APP_DIR", tmp_path)
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
created = await cl.post(
|
||||
"/api/v1/agent/job-profiles",
|
||||
headers=headers,
|
||||
json={
|
||||
"id": "templated-job",
|
||||
"query": "Site",
|
||||
"auth_broker": "keyring",
|
||||
"schedule": "*/30 * * * *",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
cron = await cl.get(
|
||||
"/api/v1/agent/job-profiles/templated-job/template",
|
||||
headers=headers,
|
||||
)
|
||||
assert cron.status_code == 200
|
||||
cron_payload = cron.json()
|
||||
assert cron_payload["status"] == "ok"
|
||||
assert cron_payload["mode"] == "cron"
|
||||
assert cron_payload["schedule_source"] == "profile"
|
||||
assert "agent job-profile-run templated-job" in cron_payload["command"]
|
||||
assert cron_payload["cron_line"].startswith("*/30 * * * * ")
|
||||
|
||||
systemd = await cl.get(
|
||||
"/api/v1/agent/job-profiles/templated-job/template",
|
||||
headers=headers,
|
||||
params={
|
||||
"mode": "systemd",
|
||||
"schedule": "*:0/20",
|
||||
"unit_name": "seedpass-nightly",
|
||||
},
|
||||
)
|
||||
assert systemd.status_code == 200
|
||||
systemd_payload = systemd.json()
|
||||
assert systemd_payload["mode"] == "systemd"
|
||||
assert systemd_payload["schedule_source"] == "provided"
|
||||
assert (
|
||||
"ExecStart=seedpass --fingerprint ABC123 agent job-profile-run templated-job"
|
||||
in systemd_payload["systemd_service"]
|
||||
)
|
||||
assert "OnCalendar=*:0/20" in systemd_payload["systemd_timer"]
|
||||
assert "template_manifest" in systemd_payload
|
||||
assert systemd_payload["template_manifest"]["job_profile_id"] == "templated-job"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_job_profile_template_post_and_verify(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(job_core, "APP_DIR", tmp_path)
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
created = await cl.post(
|
||||
"/api/v1/agent/job-profiles",
|
||||
headers=headers,
|
||||
json={"id": "verify-job", "query": "Site", "auth_broker": "keyring"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
rendered = await cl.post(
|
||||
"/api/v1/agent/job-profiles/verify-job/template",
|
||||
headers=headers,
|
||||
json={"mode": "cron", "schedule": "0 * * * *", "include_manifest": True},
|
||||
)
|
||||
assert rendered.status_code == 200
|
||||
rendered_payload = rendered.json()
|
||||
assert rendered_payload["status"] == "ok"
|
||||
assert rendered_payload["schedule"] == "0 * * * *"
|
||||
assert "template_manifest" in rendered_payload
|
||||
|
||||
verified = await cl.post(
|
||||
"/api/v1/agent/job-profiles/verify-job/template/verify",
|
||||
headers=headers,
|
||||
json={"template": rendered_payload},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
verified_payload = verified.json()
|
||||
assert verified_payload["status"] == "ok"
|
||||
assert verified_payload["valid"] is True
|
||||
|
||||
tampered = dict(rendered_payload)
|
||||
tampered["command"] = "seedpass --fingerprint ABC123 agent job-profile-run altered"
|
||||
bad = await cl.post(
|
||||
"/api/v1/agent/job-profiles/verify-job/template/verify",
|
||||
headers=headers,
|
||||
json={"template": tampered},
|
||||
)
|
||||
assert bad.status_code == 200
|
||||
bad_payload = bad.json()
|
||||
assert bad_payload["valid"] is False
|
||||
assert "template_hash_sha256" in bad_payload["mismatches"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_recovery_split_recover_api(client, monkeypatch, tmp_path):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
secret = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
split = await cl.post(
|
||||
"/api/v1/agent/recovery/split",
|
||||
headers=headers,
|
||||
json={"secret": secret, "shares": 5, "threshold": 3, "label": "api-recovery"},
|
||||
)
|
||||
assert split.status_code == 200
|
||||
split_payload = split.json()
|
||||
assert split_payload["status"] == "ok"
|
||||
shares = split_payload["shares"]
|
||||
assert len(shares) == 5
|
||||
|
||||
recover = await cl.post(
|
||||
"/api/v1/agent/recovery/recover",
|
||||
headers=headers,
|
||||
json={"shares": shares[:3], "reveal": True},
|
||||
)
|
||||
assert recover.status_code == 200
|
||||
recover_payload = recover.json()
|
||||
assert recover_payload["status"] == "ok"
|
||||
assert recover_payload["secret"] == secret
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_recovery_drill_and_verify_api(client, monkeypatch, tmp_path):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
missing = tmp_path / "missing-backup.enc"
|
||||
|
||||
drill = await cl.post(
|
||||
"/api/v1/agent/recovery/drill",
|
||||
headers=headers,
|
||||
json={"backup_path": str(missing), "simulated": True, "max_age_days": 7},
|
||||
)
|
||||
assert drill.status_code == 200
|
||||
drill_payload = drill.json()
|
||||
assert drill_payload["status"] == "ok"
|
||||
assert drill_payload["report"]["status"] == "warning"
|
||||
|
||||
listed = await cl.get("/api/v1/agent/recovery/drills", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
listed_payload = listed.json()
|
||||
assert listed_payload["count"] >= 1
|
||||
|
||||
verify = await cl.post(
|
||||
"/api/v1/agent/recovery/drills/verify",
|
||||
headers=headers,
|
||||
json={"limit": 50},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
verify_payload = verify.json()
|
||||
assert verify_payload["status"] == "ok"
|
||||
assert verify_payload["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_recovery_drill_verify_detects_tamper(
|
||||
client, monkeypatch, tmp_path
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(recovery_core, "APP_DIR", tmp_path)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
missing = tmp_path / "missing-backup.enc"
|
||||
|
||||
created = await cl.post(
|
||||
"/api/v1/agent/recovery/drill",
|
||||
headers=headers,
|
||||
json={"backup_path": str(missing), "simulated": True},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
log_path = tmp_path / "agent_recovery_drills.log"
|
||||
lines = [ln for ln in log_path.read_text(encoding="utf-8").splitlines() if ln]
|
||||
payload = json.loads(lines[-1])
|
||||
payload["backup_exists"] = True
|
||||
lines[-1] = json.dumps(payload, sort_keys=True)
|
||||
log_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
verify = await cl.post(
|
||||
"/api/v1/agent/recovery/drills/verify",
|
||||
headers=headers,
|
||||
json={"limit": 50},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
verify_payload = verify.json()
|
||||
assert verify_payload["valid"] is False
|
||||
assert any("sig_mismatch_line" in v for v in verify_payload["errors"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_backup_parent_seed_blocked_when_high_risk_locked(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
api.app.state.pm.parent_seed = "seed"
|
||||
api.app.state.pm.current_fingerprint = "ABC123"
|
||||
api.app.state.pm.encryption_manager = SimpleNamespace(
|
||||
encrypt_and_save_file=lambda data, path: None,
|
||||
resolve_relative_path=lambda p: p,
|
||||
)
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path)
|
||||
isolation_core.set_high_risk_factor("factor-xyz")
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{"secret_isolation": {"enabled": True, "high_risk_kinds": ["seed"]}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
}
|
||||
res = await cl.post(
|
||||
"/api/v1/vault/backup-parent-seed",
|
||||
json={"path": "seed.enc", "confirm": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "policy_deny:high_risk_locked"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_backup_parent_seed_requires_approval_when_agent_profile(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
api.app.state.pm.parent_seed = "seed"
|
||||
api.app.state.pm.encryption_manager = SimpleNamespace(
|
||||
encrypt_and_save_file=lambda data, path: None,
|
||||
resolve_relative_path=lambda p: p,
|
||||
)
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
}
|
||||
res = await cl.post(
|
||||
"/api/v1/vault/backup-parent-seed",
|
||||
json={"path": "seed.enc", "confirm": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "policy_deny:approval_required"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_backup_parent_seed_allows_with_approval_when_agent_profile(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
api.app.state.pm.parent_seed = "seed"
|
||||
called = {}
|
||||
api.app.state.pm.encryption_manager = SimpleNamespace(
|
||||
encrypt_and_save_file=lambda data, path: called.setdefault("path", path),
|
||||
resolve_relative_path=lambda p: p,
|
||||
)
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
approval = approval_core.issue_approval(
|
||||
action="reveal_parent_seed",
|
||||
ttl_seconds=300,
|
||||
uses=1,
|
||||
resource="vault:parent-seed",
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
"X-SeedPass-Approval-Id": approval["id"],
|
||||
}
|
||||
res = await cl.post(
|
||||
"/api/v1/vault/backup-parent-seed",
|
||||
json={"path": "seed.enc", "confirm": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "saved", "path": "seed.enc"}
|
||||
assert str(called["path"]) == "seed.enc"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_backup_parent_seed_path_traversal_blocked(client, tmp_path):
|
||||
cl, token = client
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ from helpers import create_vault
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.sync_conflict import merge_index_payloads
|
||||
|
||||
|
||||
def _setup_mgr(path: Path):
|
||||
@@ -32,12 +33,22 @@ def test_merge_modified_ts():
|
||||
assert emb.retrieve_entry(idx0)["username"] == ""
|
||||
|
||||
ema.modify_entry(idx0, username="ua")
|
||||
data_a = va.load_index()
|
||||
data_a["entries"][str(idx0)]["modified_ts"] = (
|
||||
int(data_a["entries"][str(idx0)].get("modified_ts", 0)) + 1
|
||||
)
|
||||
va.save_index(data_a)
|
||||
delta_a = va.get_encrypted_index() or b""
|
||||
vb.decrypt_and_save_index_from_nostr(delta_a, merge=True)
|
||||
emb.clear_cache()
|
||||
assert emb.retrieve_entry(idx0)["username"] == "ua"
|
||||
|
||||
emb.modify_entry(idx1, username="ub")
|
||||
data_b = vb.load_index()
|
||||
data_b["entries"][str(idx1)]["modified_ts"] = (
|
||||
int(data_b["entries"][str(idx1)].get("modified_ts", 0)) + 1
|
||||
)
|
||||
vb.save_index(data_b)
|
||||
delta_b = vb.get_encrypted_index() or b""
|
||||
va.decrypt_and_save_index_from_nostr(delta_b, merge=True)
|
||||
ema.clear_cache()
|
||||
@@ -47,3 +58,418 @@ def test_merge_modified_ts():
|
||||
assert ema.retrieve_entry(idx1)["username"] == "ub"
|
||||
assert emb.retrieve_entry(idx0)["username"] == "ua"
|
||||
assert emb.retrieve_entry(idx1)["username"] == "ub"
|
||||
|
||||
|
||||
def test_merge_replay_idempotent_through_vault_boundary():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
va, ema = _setup_mgr(base / "A")
|
||||
vb, emb = _setup_mgr(base / "B")
|
||||
|
||||
idx = ema.add_entry("svc", 12)
|
||||
snapshot = va.get_encrypted_index() or b""
|
||||
vb.decrypt_and_save_index_from_nostr(snapshot, merge=False)
|
||||
ema.modify_entry(idx, username="alice")
|
||||
data_a = va.load_index()
|
||||
data_a["entries"][str(idx)]["modified_ts"] = (
|
||||
int(data_a["entries"][str(idx)].get("modified_ts", 0)) + 1
|
||||
)
|
||||
va.save_index(data_a)
|
||||
payload = va.get_encrypted_index() or b""
|
||||
|
||||
# First merge apply.
|
||||
vb.decrypt_and_save_index_from_nostr(payload, merge=True)
|
||||
emb.clear_cache()
|
||||
first = vb.load_index()
|
||||
assert emb.retrieve_entry(idx)["username"] == "alice"
|
||||
|
||||
# Replay same payload should be idempotent.
|
||||
vb.decrypt_and_save_index_from_nostr(payload, merge=True)
|
||||
emb.clear_cache()
|
||||
second = vb.load_index()
|
||||
assert second == first
|
||||
|
||||
|
||||
def test_merge_conflict_equal_timestamp_order_independent():
|
||||
current = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 100,
|
||||
"username": "alice",
|
||||
}
|
||||
},
|
||||
}
|
||||
incoming_a = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 100,
|
||||
"username": "bob",
|
||||
}
|
||||
},
|
||||
}
|
||||
incoming_b = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 100,
|
||||
"username": "carol",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
ab = merge_index_payloads(
|
||||
merge_index_payloads(current, incoming_a, source_tag="a"),
|
||||
incoming_b,
|
||||
source_tag="b",
|
||||
)
|
||||
ba = merge_index_payloads(
|
||||
merge_index_payloads(current, incoming_b, source_tag="b"),
|
||||
incoming_a,
|
||||
source_tag="a",
|
||||
)
|
||||
assert ab["entries"]["1"] == ba["entries"]["1"]
|
||||
assert ab["_sync_meta"]["strategy"] == "modified_ts_hash_tombstone_v2"
|
||||
assert sorted(ab["_sync_meta"]["sources"]) == ["a", "b"]
|
||||
|
||||
|
||||
def test_merge_conflict_equal_timestamp_field_level_union():
|
||||
current = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 100,
|
||||
"username": "",
|
||||
"url": "https://example.com",
|
||||
"notes": "",
|
||||
}
|
||||
},
|
||||
}
|
||||
incoming = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 100,
|
||||
"username": "alice",
|
||||
"url": "",
|
||||
"notes": "prod account",
|
||||
"archived": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
merged = merge_index_payloads(current, incoming, source_tag="n1")
|
||||
entry = merged["entries"]["1"]
|
||||
assert entry["username"] == "alice"
|
||||
assert entry["url"] == "https://example.com"
|
||||
assert entry["notes"] == "prod account"
|
||||
assert entry["archived"] is True
|
||||
|
||||
|
||||
def test_merge_conflict_equal_timestamp_field_union_order_independent():
|
||||
base = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 200,
|
||||
"username": "",
|
||||
"url": "",
|
||||
"notes": "",
|
||||
}
|
||||
},
|
||||
}
|
||||
a = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 200,
|
||||
"username": "alice",
|
||||
"url": "",
|
||||
"notes": "",
|
||||
}
|
||||
},
|
||||
}
|
||||
b = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 200,
|
||||
"username": "",
|
||||
"url": "https://example.com",
|
||||
"notes": "note",
|
||||
}
|
||||
},
|
||||
}
|
||||
ab = merge_index_payloads(
|
||||
merge_index_payloads(base, a, source_tag="a"), b, source_tag="b"
|
||||
)
|
||||
ba = merge_index_payloads(
|
||||
merge_index_payloads(base, b, source_tag="b"), a, source_tag="a"
|
||||
)
|
||||
assert ab["entries"]["1"] == ba["entries"]["1"]
|
||||
assert ab["entries"]["1"]["username"] == "alice"
|
||||
assert ab["entries"]["1"]["url"] == "https://example.com"
|
||||
assert ab["entries"]["1"]["notes"] == "note"
|
||||
|
||||
|
||||
def test_merge_equal_timestamp_tags_and_custom_fields_union():
|
||||
base = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"5": {
|
||||
"kind": "password",
|
||||
"label": "svc",
|
||||
"modified_ts": 500,
|
||||
"tags": ["prod"],
|
||||
"custom_fields": [{"name": "owner", "value": "ops"}],
|
||||
}
|
||||
},
|
||||
}
|
||||
incoming = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"5": {
|
||||
"kind": "password",
|
||||
"label": "svc",
|
||||
"modified_ts": 500,
|
||||
"tags": ["critical", "prod"],
|
||||
"custom_fields": [{"name": "env", "value": "us-east-1"}],
|
||||
}
|
||||
},
|
||||
}
|
||||
merged = merge_index_payloads(base, incoming, source_tag="b")
|
||||
entry = merged["entries"]["5"]
|
||||
assert entry["tags"] == ["critical", "prod"]
|
||||
assert entry["custom_fields"] == [
|
||||
{"name": "env", "value": "us-east-1"},
|
||||
{"name": "owner", "value": "ops"},
|
||||
]
|
||||
|
||||
|
||||
def test_merge_tombstone_removes_older_entry():
|
||||
current = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"2": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 100,
|
||||
"username": "alive",
|
||||
}
|
||||
},
|
||||
}
|
||||
incoming = {
|
||||
"schema_version": 4,
|
||||
"entries": {},
|
||||
"_sync_meta": {
|
||||
"tombstones": {
|
||||
"2": {
|
||||
"deleted_ts": 101,
|
||||
"entry_hash": "abc",
|
||||
"event_hash": "f" * 64,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
merged = merge_index_payloads(current, incoming, source_tag="b")
|
||||
assert "2" not in merged["entries"]
|
||||
assert merged["_sync_meta"]["tombstones"]["2"]["deleted_ts"] == 101
|
||||
|
||||
|
||||
def test_merge_tombstone_order_independent():
|
||||
base = {
|
||||
"schema_version": 4,
|
||||
"entries": {"1": {"kind": "password", "label": "x", "modified_ts": 9}},
|
||||
}
|
||||
a = {
|
||||
"schema_version": 4,
|
||||
"entries": {},
|
||||
"_sync_meta": {
|
||||
"tombstones": {
|
||||
"1": {
|
||||
"deleted_ts": 10,
|
||||
"entry_hash": "a",
|
||||
"event_hash": "e" * 64,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
b = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "new",
|
||||
"modified_ts": 10,
|
||||
"username": "u",
|
||||
}
|
||||
},
|
||||
}
|
||||
ab = merge_index_payloads(
|
||||
merge_index_payloads(base, a, source_tag="a"), b, source_tag="b"
|
||||
)
|
||||
ba = merge_index_payloads(
|
||||
merge_index_payloads(base, b, source_tag="b"), a, source_tag="a"
|
||||
)
|
||||
assert ab["entries"] == ba["entries"]
|
||||
assert ab["_sync_meta"]["tombstones"] == ba["_sync_meta"]["tombstones"]
|
||||
|
||||
|
||||
def test_merge_replay_same_payload_is_idempotent():
|
||||
base = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "svc",
|
||||
"modified_ts": 10,
|
||||
"username": "",
|
||||
}
|
||||
},
|
||||
}
|
||||
incoming = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {
|
||||
"kind": "password",
|
||||
"label": "svc",
|
||||
"modified_ts": 11,
|
||||
"username": "alice",
|
||||
}
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 11},
|
||||
}
|
||||
once = merge_index_payloads(base, incoming, source_tag="peer-a")
|
||||
twice = merge_index_payloads(once, incoming, source_tag="peer-a")
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_merge_replay_older_payload_does_not_override_newer():
|
||||
current = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"9": {
|
||||
"kind": "password",
|
||||
"label": "svc",
|
||||
"modified_ts": 50,
|
||||
"username": "new",
|
||||
}
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 50},
|
||||
}
|
||||
stale = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"9": {
|
||||
"kind": "password",
|
||||
"label": "svc",
|
||||
"modified_ts": 40,
|
||||
"username": "old",
|
||||
}
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 40},
|
||||
}
|
||||
merged = merge_index_payloads(current, stale, source_tag="peer-b")
|
||||
assert merged["entries"]["9"]["username"] == "new"
|
||||
assert merged["_sync_meta"]["last_merge_ts"] == 50
|
||||
|
||||
|
||||
def test_merge_tombstone_replay_keeps_older_readd_deleted():
|
||||
current = {
|
||||
"schema_version": 4,
|
||||
"entries": {},
|
||||
"_sync_meta": {
|
||||
"last_merge_ts": 70,
|
||||
"tombstones": {"3": {"deleted_ts": 70, "event_hash": "d" * 64}},
|
||||
},
|
||||
}
|
||||
stale_readd = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"3": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 69,
|
||||
"username": "stale",
|
||||
}
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 69},
|
||||
}
|
||||
merged = merge_index_payloads(current, stale_readd, source_tag="peer-c")
|
||||
assert "3" not in merged["entries"]
|
||||
assert merged["_sync_meta"]["tombstones"]["3"]["deleted_ts"] == 70
|
||||
|
||||
|
||||
def test_merge_tombstone_allows_newer_recreate():
|
||||
current = {
|
||||
"schema_version": 4,
|
||||
"entries": {},
|
||||
"_sync_meta": {
|
||||
"last_merge_ts": 70,
|
||||
"tombstones": {"3": {"deleted_ts": 70, "event_hash": "d" * 64}},
|
||||
},
|
||||
}
|
||||
newer_readd = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"3": {
|
||||
"kind": "password",
|
||||
"label": "x",
|
||||
"modified_ts": 71,
|
||||
"username": "new",
|
||||
}
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 71},
|
||||
}
|
||||
merged = merge_index_payloads(current, newer_readd, source_tag="peer-d")
|
||||
assert merged["entries"]["3"]["username"] == "new"
|
||||
assert "3" not in merged["_sync_meta"]["tombstones"]
|
||||
assert merged["_sync_meta"]["last_merge_ts"] == 71
|
||||
|
||||
|
||||
def test_merge_last_merge_ts_order_independent():
|
||||
base = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {"kind": "password", "label": "a", "modified_ts": 90},
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 90},
|
||||
}
|
||||
a = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"1": {"kind": "password", "label": "a", "modified_ts": 100},
|
||||
},
|
||||
"_sync_meta": {"last_merge_ts": 100},
|
||||
}
|
||||
b = {
|
||||
"schema_version": 4,
|
||||
"entries": {},
|
||||
"_sync_meta": {
|
||||
"last_merge_ts": 95,
|
||||
"tombstones": {"2": {"deleted_ts": 95, "event_hash": "a" * 64}},
|
||||
},
|
||||
}
|
||||
ab = merge_index_payloads(
|
||||
merge_index_payloads(base, a, source_tag="a"), b, source_tag="b"
|
||||
)
|
||||
ba = merge_index_payloads(
|
||||
merge_index_payloads(base, b, source_tag="b"), a, source_tag="a"
|
||||
)
|
||||
assert ab["_sync_meta"]["last_merge_ts"] == ba["_sync_meta"]["last_merge_ts"] == 100
|
||||
+287
-6
@@ -1,4 +1,5 @@
|
||||
import sys
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,6 +13,8 @@ from seedpass.cli import api as cli_api
|
||||
from seedpass import cli
|
||||
from seedpass.core.entry_types import EntryType
|
||||
import seedpass.core.agent_export_policy as export_policy
|
||||
import seedpass.core.agent_approval as approval_core
|
||||
import seedpass.core.agent_secret_isolation as isolation_core
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -27,7 +30,7 @@ def test_entry_list(monkeypatch):
|
||||
entry_manager=SimpleNamespace(list_entries=list_entries),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm)
|
||||
result = runner.invoke(app, ["entry", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "Site" in result.stdout
|
||||
@@ -87,15 +90,142 @@ def test_vault_export(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_vault_export_denied_for_agent_profile_by_default(monkeypatch, tmp_path):
|
||||
events = {}
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data")
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"seedpass.cli.vault.record_export_policy_event",
|
||||
lambda event, details: events.setdefault("event", (event, details)),
|
||||
)
|
||||
out_path = tmp_path / "out.json"
|
||||
result = runner.invoke(
|
||||
app, ["vault", "export", "--file", str(out_path), "--agent-profile"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Policy denied full vault export" in result.stdout
|
||||
assert "policy_deny:full_export_blocked" in result.stdout
|
||||
assert events["event"][0] == "export_denied"
|
||||
|
||||
|
||||
def test_vault_export_policy_filtered_includes_manifest(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"allow_kinds": ["password"],
|
||||
"allow_export_import": False,
|
||||
"output": {"safe_output_default": True, "redact_fields": ["value"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
class Enc:
|
||||
def encrypt_data(self, payload):
|
||||
return payload
|
||||
|
||||
index_data = {
|
||||
"schema_version": 4,
|
||||
"entries": {
|
||||
"0": {"kind": "password", "label": "ok"},
|
||||
"1": {"kind": "totp", "label": "skip"},
|
||||
},
|
||||
}
|
||||
pm = SimpleNamespace(
|
||||
vault=SimpleNamespace(load_index=lambda: index_data, encryption_manager=Enc()),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
out_path = tmp_path / "filtered.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"vault",
|
||||
"export",
|
||||
"--file",
|
||||
str(out_path),
|
||||
"--agent-profile",
|
||||
"--policy-filtered",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(out_path.read_text(encoding="utf-8"))
|
||||
manifest = payload["_export_manifest"]
|
||||
assert manifest["mode"] == "policy_filtered"
|
||||
assert manifest["allow_kinds"] == ["password"]
|
||||
assert manifest["included_entry_indexes"] == ["0"]
|
||||
|
||||
|
||||
def test_vault_export_agent_requires_approval_for_full_export(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"allow_kinds": ["password"],
|
||||
"allow_export_import": True,
|
||||
"export": {"allow_full_vault": True},
|
||||
"approvals": {"require_for": ["export"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data")
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace())
|
||||
|
||||
out_path = tmp_path / "needs-approval.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"vault",
|
||||
"export",
|
||||
"--file",
|
||||
str(out_path),
|
||||
"--agent-profile",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "policy_deny:approval_required" in result.stdout
|
||||
|
||||
|
||||
def test_vault_export_agent_full_succeeds_with_approval(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"allow_kinds": ["password"],
|
||||
"allow_export_import": True,
|
||||
"export": {"allow_full_vault": True},
|
||||
"approvals": {"require_for": ["export"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data")
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace())
|
||||
approval = approval_core.issue_approval(
|
||||
action="export",
|
||||
ttl_seconds=300,
|
||||
uses=1,
|
||||
resource="vault:full",
|
||||
)
|
||||
|
||||
out_path = tmp_path / "approved.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"vault",
|
||||
"export",
|
||||
"--file",
|
||||
str(out_path),
|
||||
"--agent-profile",
|
||||
"--approval-id",
|
||||
approval["id"],
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert out_path.read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_vault_import(monkeypatch, tmp_path):
|
||||
@@ -199,7 +329,7 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
|
||||
pm = SimpleNamespace(
|
||||
handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm)
|
||||
out_path = tmp_path / "seed.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -210,6 +340,116 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
|
||||
assert called["path"] == out_path
|
||||
|
||||
|
||||
def test_vault_reveal_parent_seed_agent_requires_approval(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def reveal(path=None, **_):
|
||||
called["path"] = path
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm)
|
||||
out_path = tmp_path / "seed.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"vault",
|
||||
"reveal-parent-seed",
|
||||
"--file",
|
||||
str(out_path),
|
||||
"--agent-profile",
|
||||
],
|
||||
input="pw\n",
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "policy_deny:approval_required" in result.stdout
|
||||
assert "path" not in called
|
||||
|
||||
|
||||
def test_vault_reveal_parent_seed_blocked_when_high_risk_locked(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(isolation_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps(
|
||||
{"secret_isolation": {"enabled": True, "high_risk_kinds": ["seed"]}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
isolation_core.set_high_risk_factor("factor-1")
|
||||
|
||||
def reveal(path=None, **_):
|
||||
called["path"] = path
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda *a, **k: pm)
|
||||
out_path = tmp_path / "seed.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"ABC123",
|
||||
"vault",
|
||||
"reveal-parent-seed",
|
||||
"--file",
|
||||
str(out_path),
|
||||
],
|
||||
input="pw\n",
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "policy_deny:high_risk_locked" in result.stdout
|
||||
assert "path" not in called
|
||||
|
||||
|
||||
def test_vault_reveal_parent_seed_agent_approval_allows(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(approval_core, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
json.dumps({"approvals": {"require_for": ["reveal_parent_seed"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def reveal(path=None, **_):
|
||||
called["path"] = path
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
approval = approval_core.issue_approval(
|
||||
action="reveal_parent_seed",
|
||||
ttl_seconds=300,
|
||||
uses=1,
|
||||
resource="vault:parent-seed",
|
||||
)
|
||||
out_path = tmp_path / "seed.enc"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"vault",
|
||||
"reveal-parent-seed",
|
||||
"--file",
|
||||
str(out_path),
|
||||
"--agent-profile",
|
||||
"--approval-id",
|
||||
approval["id"],
|
||||
],
|
||||
input="pw\n",
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert called["path"] == out_path
|
||||
|
||||
|
||||
def test_nostr_get_pubkey(monkeypatch):
|
||||
pm = SimpleNamespace(
|
||||
nostr_client=SimpleNamespace(
|
||||
@@ -404,17 +644,58 @@ def test_capabilities_text():
|
||||
assert result.exit_code == 0
|
||||
assert "SeedPass Capabilities" in result.stdout
|
||||
assert "Auth brokers" in result.stdout
|
||||
assert "Sync safety" in result.stdout
|
||||
assert "Security posture checks" in result.stdout
|
||||
|
||||
|
||||
def test_capabilities_json():
|
||||
result = runner.invoke(app, ["capabilities", "--format", "json"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["schema_version"] == 1
|
||||
assert "cli" in payload["interfaces"]
|
||||
assert "agent" in payload["interfaces"]["cli"]["root_commands"]
|
||||
assert (
|
||||
"/api/v1/agent/job-profiles/{job_id}/template/verify"
|
||||
in payload["interfaces"]["api"]["discovery"]
|
||||
)
|
||||
assert "/api/v1/agent/recovery/split" in payload["interfaces"]["api"]["discovery"]
|
||||
assert (
|
||||
"/api/v1/agent/recovery/drills/verify"
|
||||
in payload["interfaces"]["api"]["discovery"]
|
||||
)
|
||||
assert "leases" in payload["security_features"]
|
||||
assert "automation" in payload["security_features"]
|
||||
assert "sync" in payload["security_features"]
|
||||
assert "policy" in payload["security_features"]
|
||||
assert payload["security_features"]["policy"]["supports_change_review"]
|
||||
assert (
|
||||
payload["security_features"]["sync"]["deterministic_conflict_resolution"]
|
||||
== "modified_ts_hash_tombstone_v2"
|
||||
)
|
||||
assert "export_controls" in payload["security_features"]
|
||||
assert payload["security_features"]["export_controls"][
|
||||
"supports_manifest_entry_hash_verification"
|
||||
]
|
||||
assert "posture" in payload["security_features"]
|
||||
assert (
|
||||
"agent posture-remediate" in payload["security_features"]["posture"]["commands"]
|
||||
)
|
||||
assert payload["security_features"]["automation"][
|
||||
"supports_persistent_job_profiles"
|
||||
]
|
||||
assert payload["security_features"]["automation"]["supports_api_templates"]
|
||||
assert (
|
||||
payload["security_features"]["automation"]["template_manifest_signing"]
|
||||
== "hmac-sha256"
|
||||
)
|
||||
assert payload["security_features"]["automation"]["enforces_policy_stamp_on_run"]
|
||||
assert payload["security_features"]["automation"]["supports_host_binding"]
|
||||
assert payload["security_features"]["recovery"]["supports_shamir_split_recover"]
|
||||
assert payload["security_features"]["recovery"]["supports_signed_drill_reports"]
|
||||
assert "identities" in payload["security_features"]
|
||||
assert "secret_isolation" in payload["security_features"]
|
||||
assert any("After unlock/login" in h for h in payload["help_hints"])
|
||||
|
||||
|
||||
def test_api_start_passes_fingerprint(monkeypatch):
|
||||
@@ -647,7 +928,7 @@ def test_entry_export_totp_denied_for_agent_profile(monkeypatch, tmp_path):
|
||||
app, ["entry", "export-totp", "--file", str(out), "--agent-profile"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Policy denied TOTP export" in result.stdout
|
||||
assert "policy_deny:kind_not_allowed" in result.stdout
|
||||
|
||||
|
||||
def test_entry_totp_codes(monkeypatch):
|
||||
|
||||
Reference in new issue
Block a user