mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Merge branch 'main' into beta
# Conflicts: # memory-update.md
This commit is contained in:
commit
982fba6404
41 files changed
+2895
-144
No files matched your search
@@ -70,6 +70,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
|
||||
- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
|
||||
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
||||
- **Entry Graph Links:** Connect entries with typed relationships (`target_id`, `relation`, `note`) for knowledge-base style navigation.
|
||||
- **Typed Search Results:** Results now display each entry's type for quicker identification.
|
||||
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
||||
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
||||
@@ -98,6 +99,7 @@ with policy and security guardrails:
|
||||
- **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`.
|
||||
- **Agent document I/O:** policy-gated filesystem import/export with `agent document-import` and `agent document-export`.
|
||||
- **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.
|
||||
|
||||
@@ -501,6 +503,13 @@ export SEEDPASS_PASSWORD='your-strong-password'
|
||||
seedpass -f <fingerprint> agent get github --ttl 30
|
||||
```
|
||||
|
||||
4. Import/export document entries from automation (requires policy `allow_export_import=true`):
|
||||
|
||||
```bash
|
||||
seedpass -f <fingerprint> agent document-import --file ./notes.md --label "Ops Notes"
|
||||
seedpass -f <fingerprint> agent document-export --entry-id 42 --out ./exports
|
||||
```
|
||||
|
||||
By default, agent policy blocks private key/seed-style entry kinds from being
|
||||
revealed through `agent get`.
|
||||
|
||||
@@ -609,19 +618,59 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also
|
||||
- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning.
|
||||
- **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data.
|
||||
- **Managed Account** – derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
|
||||
- **Document** – store editable text content plus a file type/extension (`md`, `txt`, `py`, `js`, `csv`, etc.) with a built-in terminal editor (`p`, `a`, `i <n>`, `e <n>`, `d <n>`, `w`, `q`).
|
||||
|
||||
The table below summarizes the extra fields stored for each entry type. Every entry includes a `label`, while only password entries track a `url`.
|
||||
All entry types include:
|
||||
- `type` and `kind`
|
||||
- `label` (title)
|
||||
- `archived`
|
||||
- `date_added` (UTC ISO-8601)
|
||||
- `date_modified` (UTC ISO-8601)
|
||||
- `links` (optional graph edges: `target_id`, `relation`, `note`)
|
||||
|
||||
The table below summarizes additional kind-specific fields.
|
||||
|
||||
| Entry Type | Extra Fields |
|
||||
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
|
||||
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
|
||||
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
||||
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
||||
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
||||
| Password | `username`, `url`, `length`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
|
||||
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, optional `notes`, optional `tags` |
|
||||
| SSH Key | `index`, optional `notes`, optional `tags` |
|
||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, optional `notes`, optional `tags` |
|
||||
| PGP Key | `index`, `key_type`, optional `user_id`, optional `notes`, optional `tags` |
|
||||
| Nostr Key Pair | `index`, optional `notes`, optional `tags` |
|
||||
| Key/Value | `key`, `value`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||
| Managed Account | `index`, `word_count`, `fingerprint`, optional `notes`, optional `tags` |
|
||||
| Document | `content`, `file_type`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||
|
||||
For a complete per-kind schema reference, see [docs/entry_types.md](docs/entry_types.md).
|
||||
|
||||
### Document Import/Export
|
||||
|
||||
SeedPass can import local text files as `document` entries and export stored documents back to your OS filesystem.
|
||||
|
||||
- CLI import:
|
||||
- `seedpass entry import-document --file ./notes.md --label "Ops Notes"`
|
||||
- CLI export:
|
||||
- `seedpass entry export-document --entry-id 42 --out ./exports`
|
||||
- API import:
|
||||
- `POST /api/v1/entry/document/import` with JSON: `{"path":"./notes.md","label":"Ops Notes"}`
|
||||
- API export:
|
||||
- `POST /api/v1/entry/{entry_id}/document/export` with JSON: `{"path":"./exports","overwrite":false}`
|
||||
|
||||
In TUI, choose `Add Entry > Document > Import Document File` to import, and use `Entry Actions > Export Document to File` on a document entry to export.
|
||||
|
||||
### Entry Graph Links
|
||||
|
||||
SeedPass entries can be connected with typed links so tags can evolve into an explicit knowledge graph.
|
||||
|
||||
- CLI:
|
||||
- `seedpass entry link-add --entry-id 1 --target-id 2 --relation references --note "used by runbook"`
|
||||
- `seedpass entry links --entry-id 1`
|
||||
- `seedpass entry link-remove --entry-id 1 --target-id 2 --relation references`
|
||||
- API:
|
||||
- `GET /api/v1/entry/{id}/links`
|
||||
- `POST /api/v1/entry/{id}/links`
|
||||
- `DELETE /api/v1/entry/{id}/links`
|
||||
|
||||
### Managing Multiple Seeds
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ This directory contains SeedPass project documentation.
|
||||
|
||||
- `ARCHITECTURE.md`
|
||||
- `SPEC.md`
|
||||
- `entry_types.md`
|
||||
- `entry_graph.md`
|
||||
- `security.md`
|
||||
- `nostr_setup.md`
|
||||
- `nostr_namespace_reset.md`
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Entry Graph Links
|
||||
|
||||
SeedPass now supports explicit relationships between entries, so your vault can evolve from tag clusters into a typed knowledge graph.
|
||||
|
||||
## Link Model
|
||||
|
||||
Every entry can include:
|
||||
|
||||
- `links`: list of objects with:
|
||||
- `target_id`: target entry index
|
||||
- `relation`: link type (for example `references`, `depends_on`, `owned_by`, `derived_from`)
|
||||
- `note`: optional free-form context
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"links": [
|
||||
{ "target_id": 42, "relation": "references", "note": "Used by deploy script" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
- Add link:
|
||||
- `seedpass entry link-add --entry-id 1 --target-id 42 --relation references --note "Used by deploy script"`
|
||||
- List links:
|
||||
- `seedpass entry links --entry-id 1`
|
||||
- Remove link:
|
||||
- `seedpass entry link-remove --entry-id 1 --target-id 42 --relation references`
|
||||
- Replace full link list:
|
||||
- `seedpass entry modify 1 --links-json '[{"target_id":42,"relation":"references","note":"Used by deploy script"}]'`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/v1/entry/{id}/links`
|
||||
- `POST /api/v1/entry/{id}/links`
|
||||
- Body: `{ "target_id": 42, "relation": "references", "note": "..." }`
|
||||
- `DELETE /api/v1/entry/{id}/links`
|
||||
- Body: `{ "target_id": 42, "relation": "references" }`
|
||||
- `PUT /api/v1/entry/{id}` also accepts a full `links` array for replacement.
|
||||
|
||||
## Search and Sync Behavior
|
||||
|
||||
- Search now matches link metadata (`relation`, `note`) and linked target labels.
|
||||
- Sync conflict resolution includes `links` in deterministic equal-timestamp union merge, alongside `tags` and `custom_fields`.
|
||||
|
||||
## Recommended Conventions
|
||||
|
||||
- Keep `relation` values short and consistent (`references`, `depends_on`, `owned_by`, `belongs_to`, `derived_from`).
|
||||
- Use `note` for human context instead of encoding meaning into long relation strings.
|
||||
- Use tags for broad grouping, links for explicit semantics.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Entry Types and Fields
|
||||
|
||||
This document defines the entry kinds supported by SeedPass, shared fields, and kind-specific fields used by the TUI and API.
|
||||
|
||||
For graph-link workflows and relationship modeling, see `docs/entry_graph.md`.
|
||||
|
||||
## Shared Fields
|
||||
|
||||
Every entry kind stores:
|
||||
|
||||
- `type`: canonical kind identifier.
|
||||
- `kind`: kind alias used for backward compatibility.
|
||||
- `label`: entry title.
|
||||
- `archived`: boolean archive flag.
|
||||
- `date_added`: UTC ISO-8601 timestamp set on creation.
|
||||
- `date_modified`: UTC ISO-8601 timestamp updated on modification.
|
||||
- `notes`: optional free-form notes.
|
||||
- `tags`: optional string list.
|
||||
- `links`: optional relationship list (`target_id`, `relation`, `note`) for graph-style connections between entries.
|
||||
|
||||
Internally, SeedPass also tracks `modified_ts` (UNIX timestamp) for deterministic merge conflict resolution.
|
||||
|
||||
## Kind-Specific Fields
|
||||
|
||||
### `password`
|
||||
|
||||
- `length`
|
||||
- `username`
|
||||
- `url`
|
||||
- `policy` (optional password policy overrides)
|
||||
- `custom_fields` (optional hidden/plain additional fields)
|
||||
|
||||
### `totp`
|
||||
|
||||
- `index` (for deterministic TOTP) or `secret` (for imported TOTP)
|
||||
- `period`
|
||||
- `digits`
|
||||
- `deterministic`
|
||||
|
||||
### `ssh`
|
||||
|
||||
- `index` (derivation index)
|
||||
|
||||
### `seed`
|
||||
|
||||
- `index` (derivation index)
|
||||
- `word_count`
|
||||
|
||||
### `pgp`
|
||||
|
||||
- `index` (derivation index)
|
||||
- `key_type`
|
||||
- `user_id`
|
||||
|
||||
### `nostr`
|
||||
|
||||
- `index` (derivation index)
|
||||
|
||||
### `key_value`
|
||||
|
||||
- `key`
|
||||
- `value`
|
||||
- `custom_fields` (optional)
|
||||
|
||||
### `managed_account`
|
||||
|
||||
- `index` (derivation index)
|
||||
- `word_count`
|
||||
- `fingerprint`
|
||||
- `custom_fields` (optional)
|
||||
|
||||
### `document`
|
||||
|
||||
- `content`
|
||||
- `file_type` (extension/type like `txt`, `md`, `py`, `js`, `csv`)
|
||||
- `custom_fields` (optional)
|
||||
|
||||
## TUI Notes
|
||||
|
||||
- Add flow: `Main Menu > Add Entry > Document`.
|
||||
- Document editing uses a built-in line editor:
|
||||
- `p` print content
|
||||
- `a` append line
|
||||
- `i <n>` insert before line
|
||||
- `e <n>` edit line
|
||||
- `d <n>` delete line
|
||||
- `w` save
|
||||
- `q` cancel
|
||||
|
||||
## API Notes
|
||||
|
||||
- Create: `POST /api/v1/entry` with `type=document` (or `kind=document`)
|
||||
- Update: `PUT /api/v1/entry/{id}` with `content`, `file_type`, `label`, `notes`, `tags`, `archived`
|
||||
- Read: `GET /api/v1/entry/{id}` returns all stored fields, including `date_added` and `date_modified`
|
||||
- File import: `POST /api/v1/entry/document/import` with `path` (+ optional `label`, `notes`, `tags`, `archived`)
|
||||
- File export: `POST /api/v1/entry/{id}/document/export` with optional `path` and `overwrite`
|
||||
- Link list: `GET /api/v1/entry/{id}/links`
|
||||
- Link add: `POST /api/v1/entry/{id}/links` with `{ "target_id": 2, "relation": "references", "note": "..." }`
|
||||
- Link remove: `DELETE /api/v1/entry/{id}/links` with `{ "target_id": 2, "relation": "references" }`
|
||||
|
||||
## CLI Notes
|
||||
|
||||
- Add directly: `seedpass entry add-document --label "Doc" --content "text" --file-type txt`
|
||||
- Import from file: `seedpass entry import-document --file ./doc.md`
|
||||
- Export to file: `seedpass entry export-document --entry-id 42 --out ./exports`
|
||||
- Agent import: `seedpass -f <fp> agent document-import --file ./doc.md --token <token>`
|
||||
- Agent export: `seedpass -f <fp> agent document-export --entry-id 42 --out ./exports --token <token>`
|
||||
- Link add: `seedpass entry link-add --entry-id 1 --target-id 2 --relation references --note "supports deployment"`
|
||||
- Link list: `seedpass entry links --entry-id 1`
|
||||
- Link remove: `seedpass entry link-remove --entry-id 1 --target-id 2 --relation references`
|
||||
@@ -22,8 +22,8 @@ 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.
|
||||
- `tags`, `custom_fields`, and `links` use canonical sorted union.
|
||||
- Optional kind-specific fields (for example document `content`/`file_type`) are backfilled when one side is empty.
|
||||
4. `archived` at equal timestamp uses conservative OR semantics.
|
||||
|
||||
## Tombstone Semantics
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
<li><a href="#" data-file="README.md" class="doc-link active">> README</a></li>
|
||||
<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="entry_types.md" class="doc-link">> Entry Types</a></li>
|
||||
<li><a href="#" data-file="entry_graph.md" class="doc-link">> Entry Graph</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>
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<div>├─► <i class="fas fa-network-wired"></i> SSH Keys</div>
|
||||
<div>├─► <i class="fas fa-lock"></i> PGP Key</div>
|
||||
<div>├─► <i class="fas fa-bolt"></i> Nostr Keys</div>
|
||||
<div>├─► <i class="fas fa-file-lines"></i> Documents</div>
|
||||
<div>└─► <i class="fas fa-users-cog"></i> Managed Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,6 +123,21 @@
|
||||
<h3>Secret Mode</h3>
|
||||
<p>Copy to clipboard directly, clearing automatically after delay.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-file-code"></i>
|
||||
<h3>Document Entries</h3>
|
||||
<p>Store rich text content with file types like <code>md</code>, <code>txt</code>, <code>py</code>, and edit in-terminal.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-clock-rotate-left"></i>
|
||||
<h3>Entry Timestamps</h3>
|
||||
<p>All entry kinds include <code>date_added</code> and <code>date_modified</code> for deterministic auditability.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-diagram-project"></i>
|
||||
<h3>Entry Graph Links</h3>
|
||||
<p>Create typed relationships between entries using <code>target_id</code>, <code>relation</code>, and <code>note</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -169,6 +185,16 @@
|
||||
<h3>Audit, Posture, Recovery</h3>
|
||||
<p>Chained audit logs, posture checks/remediation, Shamir split-drills, and deterministic export validation.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-file-import"></i>
|
||||
<h3>Agent Document I/O</h3>
|
||||
<p>Policy-gated <code>agent document-import</code> and <code>agent document-export</code> for controlled filesystem workflows.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="fas fa-share-nodes"></i>
|
||||
<h3>Graph-Aware Entry Ops</h3>
|
||||
<p>Automate knowledge links with <code>entry link-add</code>, <code>entry links</code>, and <code>entry link-remove</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -191,6 +217,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-blocks mt-2">
|
||||
<div class="info-block">
|
||||
<h3><span class="accent">></span> Knowledge Graph Example</h3>
|
||||
<pre class="graph-snippet">[12] Ops Runbook (document)
|
||||
├─ references ──> [7] API Token (key_value)
|
||||
├─ depends_on ──> [3] Deploy SSH (ssh)
|
||||
└─ rotates_with -> [9] Prod MFA (totp)
|
||||
|
||||
Query: "depends_on" or "deploy ssh" -> returns entry 12</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-blocks">
|
||||
<div class="info-block">
|
||||
<h3><span class="accent">></span> Recovery via Nostr</h3>
|
||||
|
||||
@@ -567,6 +567,19 @@ a:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-snippet {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 2px solid var(--accent-secondary);
|
||||
border-radius: 4px;
|
||||
background: rgba(8, 10, 12, 0.45);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.tui-footer {
|
||||
display: flex;
|
||||
|
||||
@@ -37,3 +37,128 @@
|
||||
- Verified with local venv:
|
||||
- `./.venv/bin/pytest -q src/tests/test_post_sync_messages.py`
|
||||
- `./.venv/bin/pytest -q src/tests/test_background_vault_sync_paths.py`
|
||||
|
||||
## Entry model expansion: document kind + universal date fields
|
||||
- Added a new `document` entry kind across core enum, TUI, API, CLI, and agent secret resolution.
|
||||
- Added universal `date_added` and `date_modified` fields for all entry kinds.
|
||||
- Creation now sets both fields.
|
||||
- Modification preserves `date_added` and updates `date_modified`.
|
||||
- Legacy entries are normalized in `_load_index()` by deriving date fields from `modified_ts`/`updated`.
|
||||
- TUI now supports `Document` creation via a built-in line editor with basic commands (`p`, `a`, `i <n>`, `e <n>`, `d <n>`, `w`, `q`).
|
||||
- Added document-specific display and edit flows in manager/display services.
|
||||
|
||||
## Sync/policy compatibility notes
|
||||
- Updated deterministic merge kind matrix to include document fields (`content`, `file_type`) for equal-timestamp backfill behavior.
|
||||
- Agent defaults now include `document` in non-private allow-kinds, and agent secret resolution returns document content for `document` entries.
|
||||
|
||||
## Tests and docs
|
||||
- Added/updated tests for:
|
||||
- document entry roundtrip and CLI add command
|
||||
- universal date field presence/update semantics across all entry kinds
|
||||
- API create/modify behavior for `document`
|
||||
- large document Nostr sync roundtrip
|
||||
- Added `docs/entry_types.md` and updated README/docs index and sync conflict docs.
|
||||
- Verified with:
|
||||
- `./.venv/bin/pytest -q src/tests/test_cli_entry_add_commands.py src/tests/test_entry_add.py src/tests/test_entry_types_roundtrip.py src/tests/test_modify_entry_validation.py src/tests/test_api.py src/tests/test_api_new_endpoints.py src/tests/test_full_sync_roundtrip.py`
|
||||
- Result: `141 passed`.
|
||||
|
||||
## Document file import/export workflows
|
||||
- Added document file I/O at core layer:
|
||||
- `EntryManager.import_document_file(path, ...)`
|
||||
- `EntryManager.export_document_file(index, output_path=None, overwrite=False)`
|
||||
- Added TUI support:
|
||||
- `Add Entry > Document` now supports `New Document` and `Import Document File`.
|
||||
- Document entry actions now include `Export Document to File`.
|
||||
- Added automation surfaces:
|
||||
- CLI: `entry import-document`, `entry export-document`
|
||||
- API: `POST /api/v1/entry/document/import`, `POST /api/v1/entry/{id}/document/export`
|
||||
- Regression coverage added for core file roundtrip, CLI commands, and API endpoints.
|
||||
|
||||
## Agent document import/export commands
|
||||
- Added dedicated agent CLI commands:
|
||||
- `seedpass agent document-import --file <path>`
|
||||
- `seedpass agent document-export <query>` (or `--entry-id`)
|
||||
- Enforcement model for both commands:
|
||||
- Requires `--fingerprint` and non-interactive auth broker password resolution.
|
||||
- Denies unless policy `allow_export_import` is enabled.
|
||||
- Denies unless `document` kind is policy-allowed (`allow_kinds` / kind export check).
|
||||
- Supports scoped token enforcement with operation scopes (`import` for import, `export` for export).
|
||||
- Export honors approval gates when policy requires `export` approval.
|
||||
- Emits structured JSON responses and append-only audit events for granted/denied outcomes.
|
||||
- Discovery updates:
|
||||
- Added `document_io` command hints to `agent bootstrap-context` payload.
|
||||
- Added `document_io` section to `seedpass capabilities --format json` security features.
|
||||
- Added CLI tests for:
|
||||
- token-scoped agent document import success path
|
||||
- token-scoped agent document export success path
|
||||
- bootstrap context includes document command hints
|
||||
- Environment note: could not run pytest in this shell because `pytest` is unavailable (`python3 -m pytest` reports module missing).
|
||||
|
||||
## Test environment note (2026-03-01)
|
||||
- Repo `.venv` already had `pytest` and `typer`; upgraded to `pytest 9.0.2` and `typer 0.24.1`.
|
||||
- New agent document I/O tests initially failed due mocked policy normalization (empty `rules` caused normalized `allow_kinds` to become empty).
|
||||
- Fixed tests by adding explicit allow rule for `document` in mocked policies.
|
||||
- Verification run:
|
||||
- `.venv/bin/pytest -q src/tests/test_document_file_io.py src/tests/test_cli_entry_add_commands.py src/tests/test_api_new_endpoints.py src/tests/test_entry_types_roundtrip.py src/tests/test_full_sync_roundtrip.py src/tests/test_cli_agent_mode.py`
|
||||
- Result: `150 passed`.
|
||||
|
||||
## Full-suite regression repair after document rollout
|
||||
- Ran full suite after enabling agent document I/O and found 12 regressions (mostly stale tests).
|
||||
- Fixed regressions by updating tests for:
|
||||
- new document add-menu option/mocks (`handle_add_document`),
|
||||
- expanded `EntryService.modify_entry` kwargs,
|
||||
- universal `date_added`/`date_modified` and `custom_fields` fields in entry expectations,
|
||||
- `list_entries(sort_by="updated")` relying on `modified_ts`,
|
||||
- legacy decrypt fallback monkeypatches to accept `salt` argument and revised iteration-call expectations.
|
||||
- Verification: `.venv/bin/pytest -q src/tests` => `891 passed, 11 skipped`.
|
||||
|
||||
## Knowledge graph links integration (entry relationships)
|
||||
- Added first-class graph edges to every entry via shared `links` field (list of `{target_id, relation, note}`).
|
||||
- Implemented core link operations in `EntryManager`:
|
||||
- `add_link(entry_id, target_id, relation, note)`
|
||||
- `remove_link(entry_id, target_id, relation=None)`
|
||||
- `get_links(entry_id)` with resolved target label/kind.
|
||||
- Added normalization/defaulting for `links` in index load path, including cache-return normalization to prevent stale in-memory schemas.
|
||||
- Extended `modify_entry(..., links=...)` to allow full links replacement with validation.
|
||||
- Search now matches link metadata (`relation`, `note`) and linked target labels, enabling graph-style discovery from existing index scans.
|
||||
- Sync conflict equal-timestamp union now includes `links` alongside `tags` and `custom_fields` for deterministic convergence.
|
||||
- Added API endpoints:
|
||||
- `GET /api/v1/entry/{id}/links`
|
||||
- `POST /api/v1/entry/{id}/links`
|
||||
- `DELETE /api/v1/entry/{id}/links`
|
||||
and `PUT /api/v1/entry/{id}` now accepts `links`.
|
||||
- Added CLI graph commands:
|
||||
- `seedpass entry link-add`
|
||||
- `seedpass entry links`
|
||||
- `seedpass entry link-remove`
|
||||
plus `entry modify --links-json`.
|
||||
- Documentation updated in `README.md`, `docs/entry_types.md`, and `docs/sync_conflict_contract.md`.
|
||||
- Validation:
|
||||
- Focused suite: `140 passed`
|
||||
- Full suite: `895 passed, 11 skipped`.
|
||||
|
||||
## Website/docs refresh for graph features
|
||||
- Added dedicated graph docs page: `docs/entry_graph.md`.
|
||||
- Updated docs indexes/navigation:
|
||||
- `docs/README.md` now lists `entry_graph.md`.
|
||||
- `landing/docs.html` sidebar now includes `Entry Graph`.
|
||||
- Updated landing page feature cards (`landing/index.html`) to highlight:
|
||||
- Entry Graph Links
|
||||
- Graph-Aware Entry Ops CLI commands
|
||||
- Updated docs cross-links:
|
||||
- `docs/entry_types.md` points to `docs/entry_graph.md`.
|
||||
- Updated README feature bullets to include Entry Graph Links.
|
||||
|
||||
## Landing graph example + help-hint refresh
|
||||
- Added a visual graph snippet block to landing architecture section (`landing/index.html`) showing linked entries and graph query behavior.
|
||||
- Styled graph snippet in `landing/style.css` (`.graph-snippet`) to match the terminal UI aesthetic.
|
||||
- Expanded help/discovery surfaces for users and agents:
|
||||
- `seedpass capabilities` now includes a `knowledge_graph` security feature block with CLI/API graph commands and sync/search behavior.
|
||||
- Capabilities API discovery now lists `/api/v1/entry/{id}/links`.
|
||||
- Capabilities text output now explicitly mentions graph links and document workflows.
|
||||
- Capabilities `help_hints` now include graph/document command pointers.
|
||||
- `agent bootstrap-context` now includes `commands.knowledge_graph` hints.
|
||||
- Root CLI help string now mentions document I/O and entry graph links.
|
||||
- Verification:
|
||||
- `.venv/bin/pytest -q src/tests/test_typer_cli.py -k "capabilities_text or capabilities_json" src/tests/test_cli_agent_mode.py -k "bootstrap_context"`
|
||||
- Result: `2 passed`.
|
||||
+5
-3
@@ -425,9 +425,7 @@ def handle_post_to_nostr(
|
||||
logging.info("Encrypted index posted to Nostr successfully.")
|
||||
else:
|
||||
client = getattr(password_manager, "nostr_client", None)
|
||||
detail = (
|
||||
getattr(client, "last_error", None) if client is not None else None
|
||||
)
|
||||
detail = getattr(client, "last_error", None) if client is not None else None
|
||||
if detail:
|
||||
print(colored(f"\N{CROSS MARK} Sync failed… {detail}", "red"))
|
||||
logging.error("Failed to post encrypted index to Nostr: %s", detail)
|
||||
@@ -1413,6 +1411,7 @@ def display_menu(
|
||||
print(color_text("6. PGP Key", "menu"))
|
||||
print(color_text("7. Key/Value", "menu"))
|
||||
print(color_text("8. Managed Account", "menu"))
|
||||
print(color_text("9. Document", "menu"))
|
||||
sub_choice = input(
|
||||
"Select entry type or press Enter to go back: "
|
||||
).strip()
|
||||
@@ -1441,6 +1440,9 @@ def display_menu(
|
||||
elif sub_choice == "8":
|
||||
password_manager.handle_add_managed_account()
|
||||
break
|
||||
elif sub_choice == "9":
|
||||
password_manager.handle_add_document()
|
||||
break
|
||||
elif not sub_choice:
|
||||
break
|
||||
else:
|
||||
|
||||
+191
-19
@@ -1021,55 +1021,78 @@ async def create_entry(
|
||||
int(entry.get("length", 12)),
|
||||
entry.get("username"),
|
||||
entry.get("url"),
|
||||
archived=entry.get("archived", False),
|
||||
notes=entry.get("notes", ""),
|
||||
custom_fields=entry.get("custom_fields"),
|
||||
tags=entry.get("tags"),
|
||||
**kwargs,
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "totp":
|
||||
index = pm.entry_manager.get_next_index()
|
||||
|
||||
totp_kwargs: dict[str, Any] = {
|
||||
"secret": entry.get("secret"),
|
||||
"index": entry.get("index"),
|
||||
"period": int(entry.get("period", 30)),
|
||||
"digits": int(entry.get("digits", 6)),
|
||||
"notes": entry.get("notes", ""),
|
||||
"archived": entry.get("archived", False),
|
||||
"deterministic": entry.get("deterministic", False),
|
||||
}
|
||||
if entry.get("tags") is not None:
|
||||
totp_kwargs["tags"] = entry.get("tags")
|
||||
uri = pm.entry_manager.add_totp(
|
||||
entry.get("label"),
|
||||
pm.KEY_TOTP_DET if entry.get("deterministic", False) else None,
|
||||
secret=entry.get("secret"),
|
||||
index=entry.get("index"),
|
||||
period=int(entry.get("period", 30)),
|
||||
digits=int(entry.get("digits", 6)),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
deterministic=entry.get("deterministic", False),
|
||||
**totp_kwargs,
|
||||
)
|
||||
return {"id": index, "uri": uri}
|
||||
|
||||
if etype == "ssh":
|
||||
ssh_kwargs: dict[str, Any] = {
|
||||
"index": entry.get("index"),
|
||||
"notes": entry.get("notes", ""),
|
||||
"archived": entry.get("archived", False),
|
||||
}
|
||||
if entry.get("tags") is not None:
|
||||
ssh_kwargs["tags"] = entry.get("tags")
|
||||
index = pm.entry_manager.add_ssh_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
**ssh_kwargs,
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "pgp":
|
||||
pgp_kwargs: dict[str, Any] = {
|
||||
"index": entry.get("index"),
|
||||
"key_type": entry.get("key_type", "ed25519"),
|
||||
"user_id": entry.get("user_id", ""),
|
||||
"notes": entry.get("notes", ""),
|
||||
"archived": entry.get("archived", False),
|
||||
}
|
||||
if entry.get("tags") is not None:
|
||||
pgp_kwargs["tags"] = entry.get("tags")
|
||||
index = pm.entry_manager.add_pgp_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
key_type=entry.get("key_type", "ed25519"),
|
||||
user_id=entry.get("user_id", ""),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
**pgp_kwargs,
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "nostr":
|
||||
nostr_kwargs: dict[str, Any] = {
|
||||
"index": entry.get("index"),
|
||||
"notes": entry.get("notes", ""),
|
||||
"archived": entry.get("archived", False),
|
||||
}
|
||||
if entry.get("tags") is not None:
|
||||
nostr_kwargs["tags"] = entry.get("tags")
|
||||
index = pm.entry_manager.add_nostr_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
**nostr_kwargs,
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
@@ -1079,6 +1102,25 @@ async def create_entry(
|
||||
entry.get("key"),
|
||||
entry.get("value"),
|
||||
notes=entry.get("notes", ""),
|
||||
custom_fields=entry.get("custom_fields"),
|
||||
tags=entry.get("tags"),
|
||||
archived=entry.get("archived", False),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "document":
|
||||
label = entry.get("label") or entry.get("title")
|
||||
document_kwargs: dict[str, Any] = {
|
||||
"file_type": entry.get("file_type", "txt"),
|
||||
"notes": entry.get("notes", ""),
|
||||
"archived": entry.get("archived", False),
|
||||
}
|
||||
if entry.get("tags") is not None:
|
||||
document_kwargs["tags"] = entry.get("tags")
|
||||
index = pm.entry_manager.add_document(
|
||||
label,
|
||||
entry.get("content", ""),
|
||||
**document_kwargs,
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
@@ -1093,6 +1135,8 @@ async def create_entry(
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
tags=entry.get("tags"),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
@@ -1125,12 +1169,140 @@ def update_entry(
|
||||
digits=entry.get("digits"),
|
||||
key=entry.get("key"),
|
||||
value=entry.get("value"),
|
||||
content=entry.get("content"),
|
||||
file_type=entry.get("file_type"),
|
||||
custom_fields=entry.get("custom_fields"),
|
||||
tags=entry.get("tags"),
|
||||
links=entry.get("links"),
|
||||
archived=entry.get("archived"),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/v1/entry/{entry_id}/links")
|
||||
def get_entry_links(
|
||||
request: Request,
|
||||
entry_id: int,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Return resolved graph links for an entry."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
try:
|
||||
links = pm.entry_manager.get_links(entry_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"entry_id": entry_id, "links": links}
|
||||
|
||||
|
||||
@app.post("/api/v1/entry/{entry_id}/links")
|
||||
def add_entry_link(
|
||||
request: Request,
|
||||
entry_id: int,
|
||||
data: dict,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Add a graph link from one entry to another."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
if "target_id" not in data:
|
||||
raise HTTPException(status_code=400, detail="missing_target_id")
|
||||
try:
|
||||
links = pm.entry_manager.add_link(
|
||||
entry_id,
|
||||
int(data.get("target_id")),
|
||||
relation=str(data.get("relation", "related_to")),
|
||||
note=str(data.get("note", "")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"entry_id": entry_id, "links": links}
|
||||
|
||||
|
||||
@app.delete("/api/v1/entry/{entry_id}/links")
|
||||
def remove_entry_link(
|
||||
request: Request,
|
||||
entry_id: int,
|
||||
data: dict,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Remove graph links from one entry to a target entry."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
if "target_id" not in data:
|
||||
raise HTTPException(status_code=400, detail="missing_target_id")
|
||||
try:
|
||||
links = pm.entry_manager.remove_link(
|
||||
entry_id,
|
||||
int(data.get("target_id")),
|
||||
relation=data.get("relation"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"entry_id": entry_id, "links": links}
|
||||
|
||||
|
||||
@app.post("/api/v1/entry/document/import")
|
||||
def import_document_entry(
|
||||
request: Request,
|
||||
data: dict,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Import a local text file as a document entry."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
|
||||
source_path = data.get("path")
|
||||
if not source_path:
|
||||
raise HTTPException(status_code=400, detail="missing_path")
|
||||
try:
|
||||
index = pm.entry_manager.import_document_file(
|
||||
source_path,
|
||||
label=data.get("label"),
|
||||
notes=data.get("notes", ""),
|
||||
archived=bool(data.get("archived", False)),
|
||||
tags=data.get("tags"),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"id": index}
|
||||
|
||||
|
||||
@app.post("/api/v1/entry/{entry_id}/document/export")
|
||||
def export_document_entry(
|
||||
request: Request,
|
||||
entry_id: int,
|
||||
data: dict,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Export a document entry to a local file path."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
|
||||
try:
|
||||
dest = pm.entry_manager.export_document_file(
|
||||
entry_id,
|
||||
output_path=data.get("path"),
|
||||
overwrite=bool(data.get("overwrite", False)),
|
||||
)
|
||||
except FileExistsError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"path": str(dest)}
|
||||
|
||||
|
||||
@app.post("/api/v1/entry/{entry_id}/archive")
|
||||
def archive_entry(
|
||||
request: Request, entry_id: int, authorization: str | None = Header(None)
|
||||
|
||||
@@ -16,7 +16,8 @@ app = typer.Typer(
|
||||
help=(
|
||||
"SeedPass command line interface. "
|
||||
"Run `seedpass capabilities` for a deterministic feature map "
|
||||
"and `seedpass <group> --help` for group-specific commands."
|
||||
"and `seedpass <group> --help` for group-specific commands, "
|
||||
"including document I/O and entry graph links."
|
||||
),
|
||||
invoke_without_command=True,
|
||||
)
|
||||
|
||||
+484
-1
@@ -113,6 +113,7 @@ DEFAULT_POLICY = {
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
],
|
||||
"label_regex": ".*",
|
||||
"path_regex": "^entry/.*$",
|
||||
@@ -152,6 +153,7 @@ DEFAULT_POLICY = {
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
],
|
||||
"deny_private_reveal": sorted(PRIVATE_KINDS),
|
||||
"allow_export_import": False,
|
||||
@@ -267,6 +269,8 @@ def _resolve_secret_for_kind(
|
||||
return service.get_totp_code(index)
|
||||
if kind == EntryType.KEY_VALUE.value:
|
||||
return str(entry.get("value", ""))
|
||||
if kind == EntryType.DOCUMENT.value:
|
||||
return str(entry.get("content", ""))
|
||||
if kind == EntryType.SEED.value:
|
||||
return pm.entry_manager.get_seed_phrase(index, pm.parent_seed)
|
||||
if kind == EntryType.MANAGED_ACCOUNT.value:
|
||||
@@ -1302,7 +1306,9 @@ def _remediation_actions(findings: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
"export_import_allowed": {
|
||||
"title": "Disable broad export/import for agent profiles",
|
||||
"commands": ["seedpass agent policy-review --file <candidate.json>"],
|
||||
"notes": ["Set export.allow_full_vault=false and keep allow_export_import=false."],
|
||||
"notes": [
|
||||
"Set export.allow_full_vault=false and keep allow_export_import=false."
|
||||
],
|
||||
},
|
||||
"secret_isolation_disabled": {
|
||||
"title": "Enable high-risk secret isolation",
|
||||
@@ -3290,6 +3296,15 @@ def agent_bootstrap_context(ctx: typer.Context) -> None:
|
||||
"agent export-check",
|
||||
"agent export-manifest-verify",
|
||||
],
|
||||
"document_io": [
|
||||
"agent document-import --file <path>",
|
||||
"agent document-export <query>",
|
||||
],
|
||||
"knowledge_graph": [
|
||||
"entry link-add --entry-id <source> --target-id <target>",
|
||||
"entry links --entry-id <source>",
|
||||
"entry link-remove --entry-id <source> --target-id <target>",
|
||||
],
|
||||
"posture": ["agent posture-check", "agent posture-remediate"],
|
||||
"secret_access": ["agent get --fingerprint <fp> <query>"],
|
||||
"discovery": ["seedpass capabilities --format json", "seedpass --help"],
|
||||
@@ -3657,6 +3672,474 @@ def agent_export_manifest_verify(
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@app.command("document-import")
|
||||
def agent_document_import(
|
||||
ctx: typer.Context,
|
||||
file: Path = typer.Option(..., "--file", help="Input file path"),
|
||||
label: Optional[str] = typer.Option(None, "--label", help="Document title override"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
tag: list[str] = typer.Option(
|
||||
[], "--tag", help="Tag to attach (repeatable)", show_default=False
|
||||
),
|
||||
archived: bool = typer.Option(False, "--archived", help="Import as archived"),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
|
||||
),
|
||||
auth_broker: str = typer.Option(
|
||||
"env",
|
||||
"--auth-broker",
|
||||
help="Non-interactive password broker (env|keyring|command)",
|
||||
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
|
||||
),
|
||||
broker_service: str = typer.Option(
|
||||
"seedpass",
|
||||
"--broker-service",
|
||||
help="Keyring service name when --auth-broker=keyring",
|
||||
),
|
||||
broker_account: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-account",
|
||||
help="Keyring account (defaults to fingerprint)",
|
||||
),
|
||||
broker_command: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-command",
|
||||
help="Command that prints password to stdout for --auth-broker=command",
|
||||
),
|
||||
token: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--token",
|
||||
help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.",
|
||||
),
|
||||
) -> None:
|
||||
"""Import a local file as a document entry with agent policy enforcement."""
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
if not fingerprint:
|
||||
raise typer.BadParameter("Specify target profile with --fingerprint.")
|
||||
password = _agent_password(
|
||||
broker=auth_broker,
|
||||
password_env=password_env,
|
||||
broker_service=broker_service,
|
||||
broker_account=broker_account or str(fingerprint),
|
||||
broker_command=broker_command,
|
||||
)
|
||||
raw_token = token or os.getenv("SEEDPASS_AGENT_TOKEN")
|
||||
|
||||
try:
|
||||
policy = _load_policy(strict=True)
|
||||
policy = _normalize_policy(policy, strict=False)
|
||||
except ValueError as exc:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "invalid_policy",
|
||||
"detail": str(exc),
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not bool(policy.get("allow_export_import", False)):
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_import_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
allowed, decision = evaluate_kind_export(policy, EntryType.DOCUMENT.value)
|
||||
if not allowed:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": decision,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_import_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
"reason": decision,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
token_meta: dict[str, Any] | None = None
|
||||
if raw_token:
|
||||
ok, token_reason, token_meta = _validate_token(
|
||||
raw_token,
|
||||
operation="import",
|
||||
kind=EntryType.DOCUMENT.value,
|
||||
label=label or file.stem,
|
||||
consume_use=True,
|
||||
)
|
||||
if not ok:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": token_reason,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_import_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
"reason": token_reason,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
pm = PasswordManager(fingerprint=fingerprint, password=password)
|
||||
service = EntryService(pm)
|
||||
try:
|
||||
index = service.import_document_file(
|
||||
file,
|
||||
label=label,
|
||||
notes=notes,
|
||||
tags=tag,
|
||||
archived=archived,
|
||||
)
|
||||
entry = service.retrieve_entry(index) or {}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "document_import_failed",
|
||||
"detail": str(exc),
|
||||
"file": str(file),
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"fingerprint": fingerprint,
|
||||
"index": index,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"label": entry.get("label", label or file.stem),
|
||||
"file_type": entry.get("file_type", file.suffix.lstrip(".").lower()),
|
||||
"source_file": str(file),
|
||||
"policy_decision": decision,
|
||||
}
|
||||
if token_meta is not None:
|
||||
payload["token_id"] = token_meta.get("id")
|
||||
payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0)
|
||||
|
||||
_append_audit_event(
|
||||
"agent_document_import_granted",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": index,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"label": payload["label"],
|
||||
"source_file": str(file),
|
||||
"token_id": (token_meta or {}).get("id"),
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@app.command("document-export")
|
||||
def agent_document_export(
|
||||
ctx: typer.Context,
|
||||
query: Optional[str] = typer.Argument(
|
||||
None, help="Document label or index query (omit when using --entry-id)"
|
||||
),
|
||||
entry_id: Optional[int] = typer.Option(
|
||||
None, "--entry-id", help="Document entry index override"
|
||||
),
|
||||
out: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--out",
|
||||
help="Output file path or directory (default: current directory)",
|
||||
),
|
||||
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite if exists"),
|
||||
approval_id: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--approval-id",
|
||||
help="Approval id when policy requires export approval",
|
||||
),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
|
||||
),
|
||||
auth_broker: str = typer.Option(
|
||||
"env",
|
||||
"--auth-broker",
|
||||
help="Non-interactive password broker (env|keyring|command)",
|
||||
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
|
||||
),
|
||||
broker_service: str = typer.Option(
|
||||
"seedpass",
|
||||
"--broker-service",
|
||||
help="Keyring service name when --auth-broker=keyring",
|
||||
),
|
||||
broker_account: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-account",
|
||||
help="Keyring account (defaults to fingerprint)",
|
||||
),
|
||||
broker_command: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-command",
|
||||
help="Command that prints password to stdout for --auth-broker=command",
|
||||
),
|
||||
token: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--token",
|
||||
help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.",
|
||||
),
|
||||
) -> None:
|
||||
"""Export a document entry to a local file with agent policy enforcement."""
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
if not fingerprint:
|
||||
raise typer.BadParameter("Specify target profile with --fingerprint.")
|
||||
if entry_id is None and not query:
|
||||
raise typer.BadParameter("Provide query or --entry-id.")
|
||||
if entry_id is not None and query:
|
||||
raise typer.BadParameter("Provide either query or --entry-id, not both.")
|
||||
|
||||
password = _agent_password(
|
||||
broker=auth_broker,
|
||||
password_env=password_env,
|
||||
broker_service=broker_service,
|
||||
broker_account=broker_account or str(fingerprint),
|
||||
broker_command=broker_command,
|
||||
)
|
||||
raw_token = token or os.getenv("SEEDPASS_AGENT_TOKEN")
|
||||
|
||||
try:
|
||||
policy = _load_policy(strict=True)
|
||||
policy = _normalize_policy(policy, strict=False)
|
||||
except ValueError as exc:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "invalid_policy",
|
||||
"detail": str(exc),
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not bool(policy.get("allow_export_import", False)):
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
allowed, decision = evaluate_kind_export(policy, EntryType.DOCUMENT.value)
|
||||
if not allowed:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": decision,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"reason": decision,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
pm = PasswordManager(fingerprint=fingerprint, password=password)
|
||||
service = EntryService(pm)
|
||||
if entry_id is None:
|
||||
assert query is not None
|
||||
matches = service.search_entries(query, kinds=[EntryType.DOCUMENT.value])
|
||||
if len(matches) != 1:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "ambiguous_or_missing",
|
||||
"match_count": len(matches),
|
||||
"matches": [
|
||||
{
|
||||
"index": idx,
|
||||
"label": label_v,
|
||||
"kind": etype.value,
|
||||
}
|
||||
for idx, label_v, _username, _url, _archived, etype in matches
|
||||
],
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"query": query,
|
||||
"reason": "ambiguous_or_missing",
|
||||
"match_count": len(matches),
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
entry_id = int(matches[0][0])
|
||||
|
||||
entry = service.retrieve_entry(entry_id)
|
||||
if not isinstance(entry, dict):
|
||||
payload = {"status": "error", "reason": "entry_not_found", "index": entry_id}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
kind = str(entry.get("type", entry.get("kind", "")))
|
||||
label = str(entry.get("label", ""))
|
||||
if kind != EntryType.DOCUMENT.value:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "entry_kind_mismatch",
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"expected_kind": EntryType.DOCUMENT.value,
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
token_meta: dict[str, Any] | None = None
|
||||
if raw_token:
|
||||
ok, token_reason, token_meta = _validate_token(
|
||||
raw_token,
|
||||
operation="export",
|
||||
kind=kind,
|
||||
label=label,
|
||||
consume_use=True,
|
||||
)
|
||||
if not ok:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": token_reason,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"index": entry_id,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"reason": token_reason,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
if approval_required(policy, "export"):
|
||||
if not approval_id:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "policy_deny:approval_required",
|
||||
"action": "export",
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"index": entry_id,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"reason": "policy_deny:approval_required",
|
||||
"action": "export",
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
ok, approval_reason = consume_approval(
|
||||
approval_id=approval_id,
|
||||
action="export",
|
||||
resource=f"entry:{kind}:{entry_id}",
|
||||
)
|
||||
if not ok:
|
||||
reason = f"policy_deny:{approval_reason}"
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": reason,
|
||||
"action": "export",
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"index": entry_id,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"reason": reason,
|
||||
"action": "export",
|
||||
"approval_id": approval_id,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
destination = service.export_document_file(entry_id, out, overwrite=overwrite)
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "document_export_failed",
|
||||
"detail": str(exc),
|
||||
"index": entry_id,
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"output_path": str(destination),
|
||||
"policy_decision": decision,
|
||||
}
|
||||
if token_meta is not None:
|
||||
payload["token_id"] = token_meta.get("id")
|
||||
payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0)
|
||||
|
||||
_append_audit_event(
|
||||
"agent_document_export_granted",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"output_path": str(destination),
|
||||
"token_id": (token_meta or {}).get("id"),
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def agent_get(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -38,6 +38,7 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"/docs",
|
||||
"/api/v1/export/check",
|
||||
"/api/v1/export/manifest/verify",
|
||||
"/api/v1/entry/{id}/links",
|
||||
"/api/v1/agent/job-profiles",
|
||||
"/api/v1/agent/job-profiles/{job_id}/run",
|
||||
"/api/v1/agent/job-profiles/{job_id}/template",
|
||||
@@ -180,6 +181,30 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"supports_manifest_entry_hash_verification": True,
|
||||
"manifest_mode": "policy_filtered",
|
||||
},
|
||||
"document_io": {
|
||||
"commands": [
|
||||
"entry import-document",
|
||||
"entry export-document",
|
||||
"agent document-import",
|
||||
"agent document-export",
|
||||
],
|
||||
"requires_agent_export_import_policy": True,
|
||||
},
|
||||
"knowledge_graph": {
|
||||
"commands": [
|
||||
"entry link-add",
|
||||
"entry links",
|
||||
"entry link-remove",
|
||||
"entry modify --links-json",
|
||||
],
|
||||
"api_endpoints": [
|
||||
"GET /api/v1/entry/{id}/links",
|
||||
"POST /api/v1/entry/{id}/links",
|
||||
"DELETE /api/v1/entry/{id}/links",
|
||||
],
|
||||
"search_includes_link_metadata": True,
|
||||
"sync_union_merge_fields": ["tags", "custom_fields", "links"],
|
||||
},
|
||||
"redaction": {
|
||||
"default_safe_output": True,
|
||||
"reveal_override": "agent get --reveal",
|
||||
@@ -214,6 +239,8 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"Run `seedpass --help` for top-level commands.",
|
||||
"Run `seedpass <group> --help` for detailed options.",
|
||||
"Use `seedpass capabilities --format json` for machine-readable discovery.",
|
||||
"For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.",
|
||||
"For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.",
|
||||
"After unlock/login, run `seedpass capabilities` before autonomous runs.",
|
||||
"For agent bootstrap context, run `seedpass agent bootstrap-context`.",
|
||||
],
|
||||
@@ -252,6 +279,8 @@ def emit_capabilities(output_format: str) -> str:
|
||||
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("- Knowledge graph links: entry link-add/links/link-remove")
|
||||
lines.append("- Document file workflows for users and agents")
|
||||
lines.append("")
|
||||
lines.append("Discovery hints:")
|
||||
for hint in data["help_hints"]:
|
||||
|
||||
@@ -108,6 +108,8 @@ def entry_get(ctx: typer.Context, query: str) -> None:
|
||||
elif etype == EntryType.TOTP.value:
|
||||
code = service.get_totp_code(index)
|
||||
typer.echo(code)
|
||||
elif etype == EntryType.DOCUMENT.value:
|
||||
typer.echo(str(entry.get("content", "")))
|
||||
else:
|
||||
typer.echo("Unsupported entry type")
|
||||
raise typer.Exit(code=1)
|
||||
@@ -302,6 +304,55 @@ def entry_add_managed_account(
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@app.command("add-document")
|
||||
def entry_add_document(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
content: str = typer.Option("", "--content", help="Document text content"),
|
||||
file_type: str = typer.Option("txt", "--file-type", help="File extension/type"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add a document entry and output its index."""
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_document(
|
||||
label,
|
||||
content,
|
||||
file_type=file_type,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@app.command("import-document")
|
||||
def entry_import_document(
|
||||
ctx: typer.Context,
|
||||
file: str = typer.Option(..., "--file", help="Input file path"),
|
||||
label: Optional[str] = typer.Option(None, "--label", help="Title override"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Import a local text file as a document entry and output its index."""
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.import_document_file(file, label=label, notes=notes)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@app.command("export-document")
|
||||
def entry_export_document(
|
||||
ctx: typer.Context,
|
||||
entry_id: int = typer.Option(..., "--entry-id", help="Document entry index"),
|
||||
out: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--out",
|
||||
help="Output file path or directory (default: current directory)",
|
||||
),
|
||||
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite if exists"),
|
||||
) -> None:
|
||||
"""Export a document entry to a local file and print the destination path."""
|
||||
service = _get_entry_service(ctx)
|
||||
dest = service.export_document_file(entry_id, out, overwrite=overwrite)
|
||||
typer.echo(str(dest))
|
||||
|
||||
|
||||
@app.command("modify")
|
||||
def entry_modify(
|
||||
ctx: typer.Context,
|
||||
@@ -316,9 +367,31 @@ def entry_modify(
|
||||
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
|
||||
key: Optional[str] = typer.Option(None, "--key", help="New key"),
|
||||
value: Optional[str] = typer.Option(None, "--value", help="New value"),
|
||||
content: Optional[str] = typer.Option(
|
||||
None, "--content", help="New document content"
|
||||
),
|
||||
file_type: Optional[str] = typer.Option(
|
||||
None, "--file-type", help="New document file type"
|
||||
),
|
||||
links_json: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--links-json",
|
||||
help="Replace links with JSON array: [{\"target_id\":1,\"relation\":\"related_to\",\"note\":\"...\"}]",
|
||||
),
|
||||
) -> None:
|
||||
"""Modify an existing entry."""
|
||||
service = _get_entry_service(ctx)
|
||||
links = None
|
||||
if links_json is not None:
|
||||
try:
|
||||
parsed = json.loads(links_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
typer.echo(f"Invalid --links-json: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
if not isinstance(parsed, list):
|
||||
typer.echo("Invalid --links-json: expected a JSON list")
|
||||
raise typer.Exit(code=1)
|
||||
links = parsed
|
||||
try:
|
||||
service.modify_entry(
|
||||
entry_id,
|
||||
@@ -330,6 +403,9 @@ def entry_modify(
|
||||
digits=digits,
|
||||
key=key,
|
||||
value=value,
|
||||
content=content,
|
||||
file_type=file_type,
|
||||
links=links,
|
||||
)
|
||||
except ValueError as e:
|
||||
typer.echo(str(e))
|
||||
@@ -337,6 +413,58 @@ def entry_modify(
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@app.command("links")
|
||||
def entry_links(
|
||||
ctx: typer.Context,
|
||||
entry_id: int = typer.Option(..., "--entry-id", help="Entry index"),
|
||||
) -> None:
|
||||
"""List graph links for an entry."""
|
||||
service = _get_entry_service(ctx)
|
||||
try:
|
||||
links = service.get_links(entry_id)
|
||||
except ValueError as exc:
|
||||
typer.echo(str(exc))
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2))
|
||||
|
||||
|
||||
@app.command("link-add")
|
||||
def entry_link_add(
|
||||
ctx: typer.Context,
|
||||
entry_id: int = typer.Option(..., "--entry-id", help="Source entry index"),
|
||||
target_id: int = typer.Option(..., "--target-id", help="Target entry index"),
|
||||
relation: str = typer.Option("related_to", "--relation", help="Link relation"),
|
||||
note: str = typer.Option("", "--note", help="Optional link note"),
|
||||
) -> None:
|
||||
"""Create a typed link between entries."""
|
||||
service = _get_entry_service(ctx)
|
||||
try:
|
||||
links = service.add_link(entry_id, target_id, relation=relation, note=note)
|
||||
except ValueError as exc:
|
||||
typer.echo(str(exc))
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2))
|
||||
|
||||
|
||||
@app.command("link-remove")
|
||||
def entry_link_remove(
|
||||
ctx: typer.Context,
|
||||
entry_id: int = typer.Option(..., "--entry-id", help="Source entry index"),
|
||||
target_id: int = typer.Option(..., "--target-id", help="Target entry index"),
|
||||
relation: Optional[str] = typer.Option(
|
||||
None, "--relation", help="Relation filter (optional)"
|
||||
),
|
||||
) -> None:
|
||||
"""Remove links from source entry to target entry."""
|
||||
service = _get_entry_service(ctx)
|
||||
try:
|
||||
links = service.remove_link(entry_id, target_id, relation=relation)
|
||||
except ValueError as exc:
|
||||
typer.echo(str(exc))
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2))
|
||||
|
||||
|
||||
@app.command("archive")
|
||||
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
|
||||
"""Archive an entry."""
|
||||
|
||||
@@ -18,6 +18,7 @@ DEFAULT_EXPORT_POLICY: dict[str, Any] = {
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
],
|
||||
"allow_export_import": False,
|
||||
"export": {"allow_full_vault": False},
|
||||
|
||||
@@ -487,6 +487,62 @@ class EntryService:
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_document(
|
||||
self,
|
||||
label: str,
|
||||
content: str,
|
||||
*,
|
||||
file_type: str = "txt",
|
||||
notes: str = "",
|
||||
tags: list[str] | None = None,
|
||||
archived: bool = False,
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_document(
|
||||
label,
|
||||
content,
|
||||
file_type=file_type,
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
archived=archived,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def import_document_file(
|
||||
self,
|
||||
file_path: str | Path,
|
||||
*,
|
||||
label: str | None = None,
|
||||
notes: str = "",
|
||||
tags: list[str] | None = None,
|
||||
archived: bool = False,
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.import_document_file(
|
||||
file_path,
|
||||
label=label,
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
archived=archived,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def export_document_file(
|
||||
self,
|
||||
entry_id: int,
|
||||
output_path: str | Path | None = None,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> Path:
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.export_document_file(
|
||||
entry_id,
|
||||
output_path=output_path,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
def modify_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
@@ -499,6 +555,12 @@ class EntryService:
|
||||
digits: int | None = None,
|
||||
key: str | None = None,
|
||||
value: str | None = None,
|
||||
content: str | None = None,
|
||||
file_type: str | None = None,
|
||||
custom_fields: list[dict[str, Any]] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
links: list[dict[str, Any]] | None = None,
|
||||
archived: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.modify_entry(
|
||||
@@ -511,9 +573,48 @@ class EntryService:
|
||||
digits=digits,
|
||||
key=key,
|
||||
value=value,
|
||||
content=content,
|
||||
file_type=file_type,
|
||||
custom_fields=custom_fields,
|
||||
tags=tags,
|
||||
links=links,
|
||||
archived=archived,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def add_link(
|
||||
self,
|
||||
entry_id: int,
|
||||
target_id: int,
|
||||
*,
|
||||
relation: str = "related_to",
|
||||
note: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
links = self._manager.entry_manager.add_link(
|
||||
entry_id, target_id, relation=relation, note=note
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return links
|
||||
|
||||
def remove_link(
|
||||
self,
|
||||
entry_id: int,
|
||||
target_id: int,
|
||||
*,
|
||||
relation: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
links = self._manager.entry_manager.remove_link(
|
||||
entry_id, target_id, relation=relation
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return links
|
||||
|
||||
def get_links(self, entry_id: int) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.get_links(entry_id)
|
||||
|
||||
def archive_entry(self, entry_id: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.archive_entry(entry_id)
|
||||
|
||||
@@ -69,6 +69,8 @@ class DisplayService:
|
||||
self._display_nostr(entry, index)
|
||||
elif entry_type == EntryType.KEY_VALUE.value:
|
||||
self._display_key_value(entry, index)
|
||||
elif entry_type == EntryType.DOCUMENT.value:
|
||||
self._display_document(entry, index)
|
||||
elif entry_type == EntryType.MANAGED_ACCOUNT.value:
|
||||
self._display_managed_account(entry, index)
|
||||
else:
|
||||
@@ -382,6 +384,51 @@ class DisplayService:
|
||||
self.manager.load_managed_account(index)
|
||||
return
|
||||
|
||||
def _display_document(self, entry: dict, _index: int) -> None:
|
||||
label = entry.get("label", "")
|
||||
file_type = entry.get("file_type", "txt")
|
||||
notes = entry.get("notes", "")
|
||||
content = str(entry.get("content", ""))
|
||||
archived = entry.get("archived", False)
|
||||
print(colored(f"Document '{label}' ({file_type})", "cyan"))
|
||||
if notes:
|
||||
print(colored(f"Notes: {notes}", "cyan"))
|
||||
tags = entry.get("tags", [])
|
||||
if tags:
|
||||
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
|
||||
print(
|
||||
colored(f"Archived Status: {'Archived' if archived else 'Active'}", "cyan")
|
||||
)
|
||||
print(color_text("\n--- BEGIN DOCUMENT ---", "index"))
|
||||
print(color_text(content, "index"))
|
||||
print(color_text("--- END DOCUMENT ---\n", "index"))
|
||||
custom_fields = entry.get("custom_fields", [])
|
||||
if custom_fields:
|
||||
print(colored("Additional Fields:", "cyan"))
|
||||
hidden_fields = []
|
||||
for field in custom_fields:
|
||||
f_label = field.get("label", "")
|
||||
f_value = field.get("value", "")
|
||||
if field.get("is_hidden"):
|
||||
hidden_fields.append((f_label, f_value))
|
||||
print(colored(f" {f_label}: [hidden]", "cyan"))
|
||||
else:
|
||||
print(colored(f" {f_label}: {f_value}", "cyan"))
|
||||
if hidden_fields:
|
||||
show = input("Reveal hidden fields? (y/N): ").strip().lower()
|
||||
if show == "y":
|
||||
for f_label, f_value in hidden_fields:
|
||||
if self.manager.secret_mode_enabled:
|
||||
if self._copy_to_clipboard(f_value):
|
||||
print(
|
||||
colored(
|
||||
f"[+] {f_label} copied to clipboard. Will clear in {self.manager.clipboard_clear_delay} seconds.",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(colored(f" {f_label}: {f_value}", "cyan"))
|
||||
|
||||
def _display_password(self, entry: dict, index: int) -> None:
|
||||
website_name = entry.get("label", entry.get("website"))
|
||||
length = entry.get("length")
|
||||
|
||||
@@ -25,8 +25,10 @@ except Exception: # pragma: no cover - fallback when orjson is missing
|
||||
USE_ORJSON = False
|
||||
import logging
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
@@ -77,15 +79,46 @@ class EntryManager:
|
||||
"""Clear the cached index data."""
|
||||
self._index_cache = None
|
||||
|
||||
def _load_index(self, force_reload: bool = False) -> Dict[str, Any]:
|
||||
if not force_reload and self._index_cache is not None:
|
||||
return self._index_cache
|
||||
@staticmethod
|
||||
def _now_unix() -> int:
|
||||
return int(time.time())
|
||||
|
||||
if self.index_file.exists():
|
||||
@staticmethod
|
||||
def _iso_from_unix(ts: int) -> str:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat()
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return self._iso_from_unix(self._now_unix())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_links(raw_links: Any) -> list[dict[str, Any]]:
|
||||
"""Return canonical, de-duplicated entry links."""
|
||||
if not isinstance(raw_links, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[tuple[int, str, str]] = set()
|
||||
for item in raw_links:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
data = self.vault.load_index()
|
||||
# Normalize legacy fields
|
||||
for entry in data.get("entries", {}).values():
|
||||
target_id = int(item.get("target_id"))
|
||||
except Exception:
|
||||
continue
|
||||
relation = str(item.get("relation", "related_to")).strip().lower()
|
||||
note = str(item.get("note", "")).strip()
|
||||
if target_id < 0 or not relation:
|
||||
continue
|
||||
key = (target_id, relation, note)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized.append(
|
||||
{"target_id": target_id, "relation": relation, "note": note}
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _normalize_entry_defaults(self, entry: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize legacy fields and fill default graph/meta fields."""
|
||||
if "type" not in entry and "kind" in entry:
|
||||
entry["type"] = entry["kind"]
|
||||
if "kind" not in entry:
|
||||
@@ -93,10 +126,7 @@ class EntryManager:
|
||||
entry.setdefault("type", entry["kind"])
|
||||
if "label" not in entry and "website" in entry:
|
||||
entry["label"] = entry["website"]
|
||||
if (
|
||||
"website" in entry
|
||||
and entry.get("type") == EntryType.PASSWORD.value
|
||||
):
|
||||
if "website" in entry and entry.get("type") == EntryType.PASSWORD.value:
|
||||
entry.pop("website", None)
|
||||
if "archived" not in entry and "blacklisted" in entry:
|
||||
entry["archived"] = entry["blacklisted"]
|
||||
@@ -106,6 +136,40 @@ class EntryManager:
|
||||
entry.pop("words", None)
|
||||
entry.setdefault("tags", [])
|
||||
entry.setdefault("modified_ts", entry.get("updated", 0))
|
||||
modified_ts = int(entry.get("modified_ts", 0) or 0)
|
||||
if "date_added" not in entry:
|
||||
entry["date_added"] = self._iso_from_unix(modified_ts)
|
||||
if "date_modified" not in entry:
|
||||
entry["date_modified"] = self._iso_from_unix(modified_ts)
|
||||
entry["links"] = self._normalize_links(entry.get("links", []))
|
||||
if entry.get("type") == EntryType.DOCUMENT.value:
|
||||
entry.setdefault("content", "")
|
||||
entry.setdefault("file_type", "txt")
|
||||
entry.setdefault("custom_fields", [])
|
||||
if entry.get("type") in (
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
EntryType.SSH.value,
|
||||
):
|
||||
entry.setdefault("custom_fields", [])
|
||||
return entry
|
||||
|
||||
def _load_index(self, force_reload: bool = False) -> Dict[str, Any]:
|
||||
if not force_reload and self._index_cache is not None:
|
||||
for entry in self._index_cache.get("entries", {}).values():
|
||||
if isinstance(entry, dict):
|
||||
self._normalize_entry_defaults(entry)
|
||||
return self._index_cache
|
||||
|
||||
if self.index_file.exists():
|
||||
try:
|
||||
data = self.vault.load_index()
|
||||
# Normalize legacy fields
|
||||
for entry in data.get("entries", {}).values():
|
||||
if isinstance(entry, dict):
|
||||
self._normalize_entry_defaults(entry)
|
||||
logger.debug("Index loaded successfully.")
|
||||
self._index_cache = data
|
||||
return data
|
||||
@@ -183,6 +247,8 @@ class EntryManager:
|
||||
try:
|
||||
index = self.get_next_index()
|
||||
data = self._load_index()
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data.setdefault("entries", {})
|
||||
entry = {
|
||||
@@ -194,9 +260,12 @@ class EntryManager:
|
||||
"type": EntryType.PASSWORD.value,
|
||||
"kind": EntryType.PASSWORD.value,
|
||||
"notes": notes,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"custom_fields": custom_fields or [],
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
policy: dict[str, Any] = {}
|
||||
@@ -271,6 +340,8 @@ class EntryManager:
|
||||
entry_id = self.get_next_index()
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
if deterministic:
|
||||
if parent_seed is None:
|
||||
@@ -284,7 +355,9 @@ class EntryManager:
|
||||
"type": EntryType.TOTP.value,
|
||||
"kind": EntryType.TOTP.value,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"index": index,
|
||||
"period": period,
|
||||
"digits": digits,
|
||||
@@ -292,6 +365,7 @@ class EntryManager:
|
||||
"notes": notes,
|
||||
"tags": tags or [],
|
||||
"deterministic": True,
|
||||
"links": [],
|
||||
}
|
||||
else:
|
||||
if secret is None:
|
||||
@@ -303,13 +377,16 @@ class EntryManager:
|
||||
"kind": EntryType.TOTP.value,
|
||||
"label": label,
|
||||
"secret": secret,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"period": period,
|
||||
"digits": digits,
|
||||
"archived": archived,
|
||||
"notes": notes,
|
||||
"tags": tags or [],
|
||||
"deterministic": False,
|
||||
"links": [],
|
||||
}
|
||||
|
||||
data["entries"][str(entry_id)] = entry
|
||||
@@ -348,6 +425,8 @@ class EntryManager:
|
||||
priv_pem, pub_pem = derive_ssh_key_pair(parent_seed, index)
|
||||
if not validate_ssh_key_pair(priv_pem, pub_pem):
|
||||
raise ValueError("Derived SSH key pair failed validation")
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
@@ -356,10 +435,14 @@ class EntryManager:
|
||||
"kind": EntryType.SSH.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
"custom_fields": [],
|
||||
"links": [],
|
||||
}
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
@@ -406,6 +489,8 @@ class EntryManager:
|
||||
priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
|
||||
if not validate_pgp_private_key(priv_key, fp):
|
||||
raise ValueError("Derived PGP key failed validation")
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
@@ -414,12 +499,15 @@ class EntryManager:
|
||||
"kind": EntryType.PGP.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"key_type": key_type,
|
||||
"user_id": user_id,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
@@ -473,6 +561,8 @@ class EntryManager:
|
||||
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
|
||||
if not validate_nostr_keys(npub, nsec):
|
||||
raise ValueError("Derived Nostr keys failed validation")
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
@@ -481,10 +571,13 @@ class EntryManager:
|
||||
"kind": EntryType.NOSTR.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
@@ -505,6 +598,8 @@ class EntryManager:
|
||||
"""Add a new generic key/value entry."""
|
||||
|
||||
index = self.get_next_index()
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
@@ -513,12 +608,15 @@ class EntryManager:
|
||||
"kind": EntryType.KEY_VALUE.value,
|
||||
"label": label,
|
||||
"key": key,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"value": value,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"custom_fields": custom_fields or [],
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
self._save_index(data)
|
||||
@@ -526,6 +624,111 @@ class EntryManager:
|
||||
self.backup_manager.create_backup()
|
||||
return index
|
||||
|
||||
def add_document(
|
||||
self,
|
||||
label: str,
|
||||
content: str,
|
||||
file_type: str = "txt",
|
||||
*,
|
||||
notes: str = "",
|
||||
archived: bool = False,
|
||||
tags: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Add a new free-form document entry."""
|
||||
|
||||
index = self.get_next_index()
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
data["entries"][str(index)] = {
|
||||
"type": EntryType.DOCUMENT.value,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"label": label,
|
||||
"content": content,
|
||||
"file_type": (file_type or "txt").strip().lower(),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
self.backup_manager.create_backup()
|
||||
return index
|
||||
|
||||
def import_document_file(
|
||||
self,
|
||||
file_path: str | Path,
|
||||
*,
|
||||
label: str | None = None,
|
||||
notes: str = "",
|
||||
archived: bool = False,
|
||||
tags: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Import a plaintext document file as a document entry."""
|
||||
|
||||
path = Path(file_path).expanduser()
|
||||
if not path.exists() or not path.is_file():
|
||||
raise FileNotFoundError(f"Document file not found: {path}")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
file_type = path.suffix.lstrip(".").lower() or "txt"
|
||||
entry_label = label if label else path.stem
|
||||
return self.add_document(
|
||||
entry_label,
|
||||
content,
|
||||
file_type=file_type,
|
||||
notes=notes,
|
||||
archived=archived,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def export_document_file(
|
||||
self,
|
||||
index: int,
|
||||
output_path: str | Path | None = None,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> Path:
|
||||
"""Export a document entry to a plaintext file on disk."""
|
||||
|
||||
entry = self.retrieve_entry(index)
|
||||
if not entry:
|
||||
raise ValueError(f"Entry not found: {index}")
|
||||
kind = str(entry.get("type", entry.get("kind", ""))).strip().lower()
|
||||
if kind != EntryType.DOCUMENT.value:
|
||||
raise ValueError("Entry is not a document entry")
|
||||
|
||||
file_type = str(entry.get("file_type", "txt")).strip().lower() or "txt"
|
||||
label = str(entry.get("label", f"document_{index}")).strip()
|
||||
stem = (
|
||||
re.sub(r"[^A-Za-z0-9._-]+", "_", label).strip("._") or f"document_{index}"
|
||||
)
|
||||
content = str(entry.get("content", ""))
|
||||
|
||||
if output_path is None:
|
||||
dest = Path.cwd() / f"{stem}.{file_type}"
|
||||
else:
|
||||
raw = Path(output_path).expanduser()
|
||||
if raw.exists() and raw.is_dir():
|
||||
dest = raw / f"{stem}.{file_type}"
|
||||
elif raw.suffix:
|
||||
dest = raw
|
||||
else:
|
||||
dest = raw / f"{stem}.{file_type}"
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists() and not overwrite:
|
||||
raise FileExistsError(f"File already exists: {dest}")
|
||||
|
||||
atomic_write(dest, lambda f: f.write(content))
|
||||
return dest
|
||||
|
||||
def get_nostr_key_pair(self, index: int, parent_seed: str) -> tuple[str, str]:
|
||||
"""Return the npub and nsec for the specified entry."""
|
||||
|
||||
@@ -575,6 +778,8 @@ class EntryManager:
|
||||
phrase = derive_seed_phrase(bip85, index, words_num)
|
||||
if not validate_seed_phrase(phrase):
|
||||
raise ValueError("Derived seed phrase failed validation")
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
data = self._load_index()
|
||||
data.setdefault("entries", {})
|
||||
@@ -583,11 +788,14 @@ class EntryManager:
|
||||
"kind": EntryType.SEED.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"word_count": words_num,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
@@ -647,6 +855,8 @@ class EntryManager:
|
||||
if not validate_seed_phrase(seed_phrase):
|
||||
raise ValueError("Derived managed account seed failed validation")
|
||||
fingerprint = generate_fingerprint(seed_phrase)
|
||||
now_unix = self._now_unix()
|
||||
now_iso = self._iso_from_unix(now_unix)
|
||||
|
||||
account_dir = self.fingerprint_dir / "accounts" / fingerprint
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -658,12 +868,15 @@ class EntryManager:
|
||||
"kind": EntryType.MANAGED_ACCOUNT.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"modified_ts": now_unix,
|
||||
"date_added": now_iso,
|
||||
"date_modified": now_iso,
|
||||
"word_count": word_count,
|
||||
"notes": notes,
|
||||
"fingerprint": fingerprint,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
self._save_index(data)
|
||||
@@ -813,6 +1026,7 @@ class EntryManager:
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
):
|
||||
entry.setdefault("custom_fields", [])
|
||||
logger.debug(
|
||||
@@ -847,8 +1061,11 @@ class EntryManager:
|
||||
digits: Optional[int] = None,
|
||||
key: Optional[str] = None,
|
||||
value: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
file_type: Optional[str] = None,
|
||||
custom_fields: List[Dict[str, Any]] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
links: List[Dict[str, Any]] | None = None,
|
||||
include_special_chars: bool | None = None,
|
||||
allowed_special_chars: str | None = None,
|
||||
special_mode: str | None = None,
|
||||
@@ -901,8 +1118,11 @@ class EntryManager:
|
||||
"digits": digits,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"content": content,
|
||||
"file_type": file_type,
|
||||
"custom_fields": custom_fields,
|
||||
"tags": tags,
|
||||
"links": links,
|
||||
"include_special_chars": include_special_chars,
|
||||
"allowed_special_chars": allowed_special_chars,
|
||||
"special_mode": special_mode,
|
||||
@@ -922,6 +1142,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
"include_special_chars",
|
||||
"allowed_special_chars",
|
||||
"special_mode",
|
||||
@@ -939,6 +1160,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
EntryType.KEY_VALUE.value: {
|
||||
"label",
|
||||
@@ -948,6 +1170,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
EntryType.MANAGED_ACCOUNT.value: {
|
||||
"label",
|
||||
@@ -956,6 +1179,17 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
EntryType.DOCUMENT.value: {
|
||||
"label",
|
||||
"content",
|
||||
"file_type",
|
||||
"archived",
|
||||
"notes",
|
||||
"tags",
|
||||
"custom_fields",
|
||||
"links",
|
||||
},
|
||||
EntryType.SSH.value: {
|
||||
"label",
|
||||
@@ -963,6 +1197,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
EntryType.PGP.value: {
|
||||
"label",
|
||||
@@ -970,6 +1205,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
EntryType.NOSTR.value: {
|
||||
"label",
|
||||
@@ -977,6 +1213,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
EntryType.SEED.value: {
|
||||
"label",
|
||||
@@ -984,6 +1221,7 @@ class EntryManager:
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"tags",
|
||||
"links",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1029,6 +1267,13 @@ class EntryManager:
|
||||
if value is not None:
|
||||
entry["value"] = value
|
||||
logger.debug(f"Updated value for index {index}.")
|
||||
elif entry_type == EntryType.DOCUMENT.value:
|
||||
if content is not None:
|
||||
entry["content"] = content
|
||||
logger.debug(f"Updated document content for index {index}.")
|
||||
if file_type is not None:
|
||||
entry["file_type"] = file_type.strip().lower()
|
||||
logger.debug(f"Updated document file type for index {index}.")
|
||||
|
||||
if archived is None and "blacklisted" in legacy:
|
||||
archived = legacy["blacklisted"]
|
||||
@@ -1052,6 +1297,9 @@ class EntryManager:
|
||||
if tags is not None:
|
||||
entry["tags"] = tags
|
||||
logger.debug(f"Updated tags for index {index}.")
|
||||
if links is not None:
|
||||
entry["links"] = self._normalize_links(links)
|
||||
logger.debug(f"Updated links for index {index}.")
|
||||
|
||||
policy_updates: dict[str, Any] = {}
|
||||
if include_special_chars is not None:
|
||||
@@ -1075,7 +1323,12 @@ class EntryManager:
|
||||
entry_policy.update(policy_updates)
|
||||
entry["policy"] = entry_policy
|
||||
|
||||
entry["modified_ts"] = int(time.time())
|
||||
entry.setdefault(
|
||||
"date_added",
|
||||
self._iso_from_unix(int(entry.get("modified_ts", 0) or 0)),
|
||||
)
|
||||
entry["modified_ts"] = self._now_unix()
|
||||
entry["date_modified"] = self._iso_from_unix(entry["modified_ts"])
|
||||
|
||||
data["entries"][str(index)] = entry
|
||||
logger.debug(
|
||||
@@ -1098,6 +1351,113 @@ class EntryManager:
|
||||
)
|
||||
raise
|
||||
|
||||
def add_link(
|
||||
self,
|
||||
index: int,
|
||||
target_id: int,
|
||||
*,
|
||||
relation: str = "related_to",
|
||||
note: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Create or update a typed relationship between two entries."""
|
||||
data = self._load_index()
|
||||
entries = data.get("entries", {})
|
||||
src = entries.get(str(index))
|
||||
dst = entries.get(str(target_id))
|
||||
if not isinstance(src, dict):
|
||||
raise ValueError(f"Entry not found: {index}")
|
||||
if not isinstance(dst, dict):
|
||||
raise ValueError(f"Target entry not found: {target_id}")
|
||||
if int(index) == int(target_id):
|
||||
raise ValueError("Self-referential links are not allowed")
|
||||
current_links = self._normalize_links(src.get("links", []))
|
||||
relation_norm = str(relation or "related_to").strip().lower()
|
||||
note_norm = str(note or "").strip()
|
||||
candidate = {
|
||||
"target_id": int(target_id),
|
||||
"relation": relation_norm,
|
||||
"note": note_norm,
|
||||
}
|
||||
current_links.append(candidate)
|
||||
src["links"] = self._normalize_links(current_links)
|
||||
src.setdefault(
|
||||
"date_added",
|
||||
self._iso_from_unix(int(src.get("modified_ts", 0) or self._now_unix())),
|
||||
)
|
||||
src["modified_ts"] = self._now_unix()
|
||||
src["date_modified"] = self._iso_from_unix(src["modified_ts"])
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
self.backup_manager.create_backup()
|
||||
return src["links"]
|
||||
|
||||
def remove_link(
|
||||
self,
|
||||
index: int,
|
||||
target_id: int,
|
||||
*,
|
||||
relation: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Remove one or more links to ``target_id`` from an entry."""
|
||||
data = self._load_index()
|
||||
entries = data.get("entries", {})
|
||||
src = entries.get(str(index))
|
||||
if not isinstance(src, dict):
|
||||
raise ValueError(f"Entry not found: {index}")
|
||||
relation_norm = str(relation).strip().lower() if relation is not None else None
|
||||
links = self._normalize_links(src.get("links", []))
|
||||
filtered = []
|
||||
for link in links:
|
||||
matches_target = int(link.get("target_id", -1)) == int(target_id)
|
||||
matches_relation = relation_norm is None or str(
|
||||
link.get("relation", "")
|
||||
).lower() == relation_norm
|
||||
if matches_target and matches_relation:
|
||||
continue
|
||||
filtered.append(link)
|
||||
if len(filtered) != len(links):
|
||||
src["links"] = filtered
|
||||
src.setdefault(
|
||||
"date_added",
|
||||
self._iso_from_unix(
|
||||
int(src.get("modified_ts", 0) or self._now_unix())
|
||||
),
|
||||
)
|
||||
src["modified_ts"] = self._now_unix()
|
||||
src["date_modified"] = self._iso_from_unix(src["modified_ts"])
|
||||
self._save_index(data)
|
||||
self.update_checksum()
|
||||
self.backup_manager.create_backup()
|
||||
return src.get("links", [])
|
||||
|
||||
def get_links(self, index: int) -> list[dict[str, Any]]:
|
||||
"""Return links for an entry with resolved target metadata."""
|
||||
data = self._load_index()
|
||||
entries = data.get("entries", {})
|
||||
src = entries.get(str(index))
|
||||
if not isinstance(src, dict):
|
||||
raise ValueError(f"Entry not found: {index}")
|
||||
links = self._normalize_links(src.get("links", []))
|
||||
resolved: list[dict[str, Any]] = []
|
||||
for link in links:
|
||||
target_id = int(link.get("target_id"))
|
||||
target = entries.get(str(target_id), {})
|
||||
target_label = None
|
||||
target_kind = None
|
||||
if isinstance(target, dict):
|
||||
target_label = target.get("label", target.get("website", ""))
|
||||
target_kind = target.get("type", target.get("kind"))
|
||||
resolved.append(
|
||||
{
|
||||
"target_id": target_id,
|
||||
"relation": str(link.get("relation", "")),
|
||||
"note": str(link.get("note", "")),
|
||||
"target_label": target_label,
|
||||
"target_kind": target_kind,
|
||||
}
|
||||
)
|
||||
return resolved
|
||||
|
||||
def archive_entry(self, index: int) -> None:
|
||||
"""Mark the specified entry as archived."""
|
||||
self.modify_entry(index, archived=True)
|
||||
@@ -1147,7 +1507,7 @@ class EntryManager:
|
||||
return entry.get("label", entry.get("website", "")).lower()
|
||||
if sort_by == "updated":
|
||||
# sort newest first
|
||||
return -int(entry.get("updated", 0))
|
||||
return -int(entry.get("modified_ts", entry.get("updated", 0)) or 0)
|
||||
raise ValueError("sort_by must be 'index', 'label', or 'updated'")
|
||||
|
||||
sorted_items = sorted(entries_data.items(), key=sort_key)
|
||||
@@ -1231,6 +1591,15 @@ class EntryManager:
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
elif etype == EntryType.DOCUMENT.value:
|
||||
print(colored(" Type: Document", "cyan"))
|
||||
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||
print(
|
||||
colored(
|
||||
f" File Type: {entry.get('file_type', 'txt')}",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||
print(
|
||||
@@ -1283,14 +1652,41 @@ class EntryManager:
|
||||
)
|
||||
url = entry.get("url", "") if etype == EntryType.PASSWORD else None
|
||||
tags = entry.get("tags", [])
|
||||
links = entry.get("links", [])
|
||||
content = entry.get("content", "") if etype == EntryType.DOCUMENT else ""
|
||||
file_type = (
|
||||
entry.get("file_type", "") if etype == EntryType.DOCUMENT else ""
|
||||
)
|
||||
archived = entry.get("archived", entry.get("blacklisted", False))
|
||||
|
||||
label_match = query_lower in label.lower()
|
||||
username_match = bool(username) and query_lower in username.lower()
|
||||
url_match = bool(url) and query_lower in url.lower()
|
||||
tags_match = any(query_lower in str(t).lower() for t in tags)
|
||||
links_match = any(
|
||||
query_lower in str(link.get("relation", "")).lower()
|
||||
or query_lower in str(link.get("note", "")).lower()
|
||||
or query_lower
|
||||
in str(
|
||||
(entries_data.get(str(link.get("target_id", ""))) or {}).get(
|
||||
"label", ""
|
||||
)
|
||||
).lower()
|
||||
for link in links
|
||||
if isinstance(link, dict)
|
||||
)
|
||||
content_match = bool(content) and query_lower in str(content).lower()
|
||||
file_type_match = bool(file_type) and query_lower in str(file_type).lower()
|
||||
|
||||
if label_match or username_match or url_match or tags_match:
|
||||
if (
|
||||
label_match
|
||||
or username_match
|
||||
or url_match
|
||||
or tags_match
|
||||
or links_match
|
||||
or content_match
|
||||
or file_type_match
|
||||
):
|
||||
results.append(
|
||||
(
|
||||
int(idx),
|
||||
@@ -1314,7 +1710,7 @@ class EntryManager:
|
||||
data = self._load_index()
|
||||
if "entries" in data and str(index) in data["entries"]:
|
||||
entry = data["entries"][str(index)]
|
||||
deleted_ts = int(time.time())
|
||||
deleted_ts = self._now_unix()
|
||||
entry_hash = hashlib.sha256(
|
||||
canonical_json_dumps(entry).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
@@ -15,6 +15,7 @@ class EntryType(str, Enum):
|
||||
NOSTR = "nostr"
|
||||
KEY_VALUE = "key_value"
|
||||
MANAGED_ACCOUNT = "managed_account"
|
||||
DOCUMENT = "document"
|
||||
|
||||
|
||||
# List of all entry type values for convenience
|
||||
|
||||
@@ -2228,6 +2228,81 @@ class PasswordManager:
|
||||
print(colored(f"Error: Failed to add {operation_name}: {e}", "red"))
|
||||
pause()
|
||||
|
||||
def _terminal_document_editor(
|
||||
self, initial_content: str = "", *, title: str = "Document Editor"
|
||||
) -> str | None:
|
||||
"""Simple line-oriented terminal editor.
|
||||
|
||||
Commands:
|
||||
- `p`: print buffer
|
||||
- `a`: append line
|
||||
- `i <n>`: insert before line n
|
||||
- `e <n>`: edit line n
|
||||
- `d <n>`: delete line n
|
||||
- `w`: save and exit
|
||||
- `q`: cancel
|
||||
"""
|
||||
|
||||
lines = initial_content.splitlines()
|
||||
print(colored(f"\n{title}", "cyan"))
|
||||
print(colored("Type 'h' for help.", "cyan"))
|
||||
|
||||
def print_help() -> None:
|
||||
print(colored("Commands: p | a | i <n> | e <n> | d <n> | w | q", "cyan"))
|
||||
|
||||
def print_lines() -> None:
|
||||
if not lines:
|
||||
print(colored("[empty]", "yellow"))
|
||||
return
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
print(colored(f"{idx:>4}: {line}", "cyan"))
|
||||
|
||||
print_help()
|
||||
print_lines()
|
||||
|
||||
while True:
|
||||
raw = input("(doc-edit)> ").rstrip()
|
||||
if not raw:
|
||||
continue
|
||||
cmd, *rest = raw.split(maxsplit=1)
|
||||
cmd = cmd.lower()
|
||||
|
||||
if cmd == "h":
|
||||
print_help()
|
||||
continue
|
||||
if cmd == "p":
|
||||
print_lines()
|
||||
continue
|
||||
if cmd == "a":
|
||||
lines.append(input("append> "))
|
||||
continue
|
||||
if cmd in {"i", "e", "d"}:
|
||||
if not rest or not rest[0].strip().isdigit():
|
||||
print(colored("Line number required.", "red"))
|
||||
continue
|
||||
line_no = int(rest[0].strip())
|
||||
if line_no < 1 or line_no > max(len(lines), 1):
|
||||
print(colored("Invalid line number.", "red"))
|
||||
continue
|
||||
if cmd == "i":
|
||||
lines.insert(line_no - 1, input("insert> "))
|
||||
elif cmd == "e":
|
||||
if line_no > len(lines):
|
||||
print(colored("Line does not exist.", "red"))
|
||||
continue
|
||||
lines[line_no - 1] = input("edit> ")
|
||||
else:
|
||||
if line_no > len(lines):
|
||||
print(colored("Line does not exist.", "red"))
|
||||
continue
|
||||
lines.pop(line_no - 1)
|
||||
continue
|
||||
if cmd == "w":
|
||||
return "\n".join(lines)
|
||||
if cmd == "q":
|
||||
return None
|
||||
print(colored("Unknown command.", "red"))
|
||||
|
||||
@pause_logging_for_ui
|
||||
def handle_add_password(self) -> None:
|
||||
self.entry_service.handle_add_password()
|
||||
@@ -2591,6 +2666,69 @@ class PasswordManager:
|
||||
print(color_text(f"Value: {value}", "deterministic"))
|
||||
self._sync_and_pause()
|
||||
|
||||
@pause_logging_for_ui
|
||||
def handle_add_document(self) -> None:
|
||||
"""Add a document entry with editable text content."""
|
||||
with self._entry_op_context("document"):
|
||||
self._clear_header_add_entry("Document")
|
||||
print("1. New Document")
|
||||
print("2. Import Document File")
|
||||
mode = input("Select option or press Enter to cancel: ").strip()
|
||||
if not mode:
|
||||
return
|
||||
if mode not in {"1", "2"}:
|
||||
print(colored("Invalid choice.", "red"))
|
||||
return
|
||||
|
||||
if mode == "2":
|
||||
file_path = input("Path to document file: ").strip()
|
||||
if not file_path:
|
||||
print(colored("Error: File path cannot be empty.", "red"))
|
||||
return
|
||||
label = input("Title override (optional): ").strip() or None
|
||||
notes = input("Notes (optional): ").strip()
|
||||
tags_input = input("Enter tags (comma-separated, optional): ").strip()
|
||||
tags = (
|
||||
[t.strip() for t in tags_input.split(",") if t.strip()]
|
||||
if tags_input
|
||||
else []
|
||||
)
|
||||
index = self.entry_manager.import_document_file(
|
||||
file_path,
|
||||
label=label,
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
)
|
||||
else:
|
||||
label = input("Title: ").strip()
|
||||
if not label:
|
||||
print(colored("Error: Title cannot be empty.", "red"))
|
||||
return
|
||||
file_type = (
|
||||
input("File type/extension (default txt): ").strip() or "txt"
|
||||
)
|
||||
notes = input("Notes (optional): ").strip()
|
||||
tags_input = input("Enter tags (comma-separated, optional): ").strip()
|
||||
tags = (
|
||||
[t.strip() for t in tags_input.split(",") if t.strip()]
|
||||
if tags_input
|
||||
else []
|
||||
)
|
||||
content = self._terminal_document_editor("", title=f"Editing: {label}")
|
||||
if content is None:
|
||||
print(colored("Document creation cancelled.", "yellow"))
|
||||
return
|
||||
index = self.entry_manager.add_document(
|
||||
label,
|
||||
content,
|
||||
file_type=file_type,
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
)
|
||||
self._mark_dirty()
|
||||
print(colored(f"\n[+] Document entry added with ID {index}.\n", "green"))
|
||||
self._sync_and_pause()
|
||||
|
||||
@pause_logging_for_ui
|
||||
def handle_add_managed_account(self) -> None:
|
||||
"""Add a managed account seed entry."""
|
||||
@@ -2789,6 +2927,20 @@ class PasswordManager:
|
||||
pause()
|
||||
refresh_entry()
|
||||
|
||||
def export_document():
|
||||
out = input(
|
||||
"Output path (file or directory, blank for current directory): "
|
||||
).strip()
|
||||
try:
|
||||
dest = self.entry_manager.export_document_file(
|
||||
index, output_path=out or None
|
||||
)
|
||||
print(colored(f"Document exported to: {dest}", "green"))
|
||||
except Exception as exc:
|
||||
print(colored(f"Export failed: {exc}", "red"))
|
||||
pause()
|
||||
refresh_entry()
|
||||
|
||||
def get_options():
|
||||
archived = current_entry.get(
|
||||
"archived", current_entry.get("blacklisted", False)
|
||||
@@ -2813,6 +2965,8 @@ class PasswordManager:
|
||||
EntryType.NOSTR.value,
|
||||
}:
|
||||
options.append(("q", "Show QR codes", show_qr))
|
||||
if entry_type == EntryType.DOCUMENT.value:
|
||||
options.append(("x", "Export Document to File", export_document))
|
||||
|
||||
return options
|
||||
|
||||
@@ -2854,6 +3008,30 @@ class PasswordManager:
|
||||
self.last_update = time.time()
|
||||
refresh_entry()
|
||||
|
||||
def edit_document_content():
|
||||
new_content = self._terminal_document_editor(
|
||||
current_entry.get("content", ""),
|
||||
title=f"Editing: {current_entry.get('label', 'Document')}",
|
||||
)
|
||||
if new_content is None:
|
||||
print(colored("Edit cancelled.", "yellow"))
|
||||
return
|
||||
self.entry_manager.modify_entry(index, content=new_content)
|
||||
self.is_dirty = True
|
||||
self.last_update = time.time()
|
||||
refresh_entry()
|
||||
|
||||
def edit_document_file_type():
|
||||
current = current_entry.get("file_type", "txt")
|
||||
new_file_type = (
|
||||
input(f"New file type/extension (current: {current}): ").strip()
|
||||
or current
|
||||
)
|
||||
self.entry_manager.modify_entry(index, file_type=new_file_type)
|
||||
self.is_dirty = True
|
||||
self.last_update = time.time()
|
||||
refresh_entry()
|
||||
|
||||
def edit_username():
|
||||
new_username = input("New username: ").strip()
|
||||
self.entry_manager.modify_entry(index, username=new_username)
|
||||
@@ -2895,6 +3073,9 @@ class PasswordManager:
|
||||
if entry_type == EntryType.KEY_VALUE.value:
|
||||
options.append(("k", "Edit Key", edit_key))
|
||||
options.append(("v", "Edit Value", edit_value))
|
||||
elif entry_type == EntryType.DOCUMENT.value:
|
||||
options.append(("c", "Edit Content", edit_document_content))
|
||||
options.append(("f", "Edit File Type", edit_document_file_type))
|
||||
|
||||
if entry_type == EntryType.PASSWORD.value:
|
||||
options.append(("u", "Edit Username", edit_username))
|
||||
@@ -3228,6 +3409,81 @@ class PasswordManager:
|
||||
label=new_label,
|
||||
tags=tags,
|
||||
)
|
||||
elif entry_type == EntryType.DOCUMENT.value:
|
||||
label = entry.get("label", "")
|
||||
content = entry.get("content", "")
|
||||
file_type = entry.get("file_type", "txt")
|
||||
blacklisted = entry.get("archived", False)
|
||||
notes = entry.get("notes", "")
|
||||
|
||||
print(
|
||||
colored(
|
||||
f"Modifying document '{label}' (Index: {index}):",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
new_label = (
|
||||
input(f'Enter new title (leave blank to keep "{label}"): ').strip()
|
||||
or label
|
||||
)
|
||||
new_file_type = (
|
||||
input(
|
||||
f'Enter file type/extension (leave blank to keep "{file_type}"): '
|
||||
).strip()
|
||||
or file_type
|
||||
)
|
||||
if input("Edit document content now? (y/N): ").strip().lower() == "y":
|
||||
edited = self._terminal_document_editor(
|
||||
content, title=f"Editing: {new_label}"
|
||||
)
|
||||
new_content = content if edited is None else edited
|
||||
else:
|
||||
new_content = content
|
||||
|
||||
blacklist_input = (
|
||||
input(
|
||||
f'Archive this entry? (Y/N, current: {"Y" if blacklisted else "N"}): '
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if blacklist_input == "":
|
||||
new_blacklisted = blacklisted
|
||||
elif blacklist_input == "y":
|
||||
new_blacklisted = True
|
||||
elif blacklist_input == "n":
|
||||
new_blacklisted = False
|
||||
else:
|
||||
self.notify(
|
||||
"Invalid input for archived status. Keeping the current status.",
|
||||
level="WARNING",
|
||||
)
|
||||
new_blacklisted = blacklisted
|
||||
|
||||
new_notes = (
|
||||
input(
|
||||
f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
|
||||
).strip()
|
||||
or notes
|
||||
)
|
||||
tags_input = input(
|
||||
"Enter tags (comma-separated, leave blank to keep current): "
|
||||
).strip()
|
||||
tags = (
|
||||
[t.strip() for t in tags_input.split(",") if t.strip()]
|
||||
if tags_input
|
||||
else None
|
||||
)
|
||||
|
||||
self.entry_manager.modify_entry(
|
||||
index,
|
||||
archived=new_blacklisted,
|
||||
notes=new_notes,
|
||||
label=new_label,
|
||||
content=new_content,
|
||||
file_type=new_file_type,
|
||||
tags=tags,
|
||||
)
|
||||
elif entry_type in (
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
@@ -3632,6 +3888,36 @@ class PasswordManager:
|
||||
print(color_text(f" Tags: {', '.join(tags)}", "index"))
|
||||
blacklisted = entry.get("archived", entry.get("blacklisted", False))
|
||||
print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index"))
|
||||
elif etype == EntryType.DOCUMENT.value:
|
||||
print(color_text(" Type: Document", "index"))
|
||||
print(color_text(f" Title: {entry.get('label', '')}", "index"))
|
||||
print(
|
||||
color_text(
|
||||
f" File Type: {entry.get('file_type', 'txt')}",
|
||||
"index",
|
||||
)
|
||||
)
|
||||
content = str(entry.get("content", ""))
|
||||
preview = content if len(content) <= 240 else f"{content[:240]}..."
|
||||
print(color_text(" Content Preview:", "index"))
|
||||
print(color_text(f"{preview}", "index"))
|
||||
notes = entry.get("notes", "")
|
||||
if notes:
|
||||
print(color_text(f" Notes: {notes}", "index"))
|
||||
tags = entry.get("tags", [])
|
||||
if tags:
|
||||
print(color_text(f" Tags: {', '.join(tags)}", "index"))
|
||||
if entry.get("date_added"):
|
||||
print(color_text(f" Date Added: {entry.get('date_added')}", "index"))
|
||||
if entry.get("date_modified"):
|
||||
print(
|
||||
color_text(
|
||||
f" Date Modified: {entry.get('date_modified')}",
|
||||
"index",
|
||||
)
|
||||
)
|
||||
blacklisted = entry.get("archived", entry.get("blacklisted", False))
|
||||
print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index"))
|
||||
elif etype == EntryType.MANAGED_ACCOUNT.value:
|
||||
print(color_text(" Type: Managed Account", "index"))
|
||||
print(color_text(f" Label: {entry.get('label', '')}", "index"))
|
||||
@@ -3671,6 +3957,10 @@ class PasswordManager:
|
||||
"index",
|
||||
)
|
||||
)
|
||||
if entry.get("date_added"):
|
||||
print(color_text(f" Date Added: {entry.get('date_added')}", "index"))
|
||||
if entry.get("date_modified"):
|
||||
print(color_text(f" Date Modified: {entry.get('date_modified')}", "index"))
|
||||
print("-" * 40)
|
||||
|
||||
@pause_logging_for_ui
|
||||
|
||||
@@ -137,7 +137,7 @@ def _prefer_entry(current: dict[str, Any], incoming: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def _union_fields_for_kind(kind: str) -> list[str]:
|
||||
common = ["notes", "tags", "custom_fields"]
|
||||
common = ["notes", "tags", "custom_fields", "links"]
|
||||
matrix = {
|
||||
"password": ["username", "url"],
|
||||
"totp": ["issuer"],
|
||||
@@ -147,6 +147,7 @@ def _union_fields_for_kind(kind: str) -> list[str]:
|
||||
"pgp": ["public_key", "key_type"],
|
||||
"nostr": ["npub", "public_key"],
|
||||
"seed": ["path", "network", "coin_type"],
|
||||
"document": ["content", "file_type"],
|
||||
}
|
||||
return matrix.get(kind, []) + common
|
||||
|
||||
@@ -170,7 +171,7 @@ def _merge_equal_ts_entries(
|
||||
for field in union_fields:
|
||||
pv = merged.get(field)
|
||||
ov = other.get(field)
|
||||
if field in ("tags", "custom_fields"):
|
||||
if field in ("tags", "custom_fields", "links"):
|
||||
merged[field] = _merge_list_union(pv, ov)
|
||||
continue
|
||||
if _is_empty(pv) and not _is_empty(ov):
|
||||
|
||||
@@ -109,6 +109,148 @@ async def test_create_and_modify_ssh_entry(client):
|
||||
assert calls["modify"][1]["notes"] == "x"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_modify_document_entry(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
def add_document(label, content, **kwargs):
|
||||
calls["create"] = (label, content, kwargs)
|
||||
return 11
|
||||
|
||||
def modify(idx, **kwargs):
|
||||
calls["modify"] = (idx, kwargs)
|
||||
|
||||
api.app.state.pm.entry_manager.add_document = add_document
|
||||
api.app.state.pm.entry_manager.modify_entry = modify
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.post(
|
||||
"/api/v1/entry",
|
||||
json={
|
||||
"type": "document",
|
||||
"title": "Runbook",
|
||||
"content": "line1\\nline2",
|
||||
"file_type": "md",
|
||||
"notes": "ops",
|
||||
"tags": ["doc", "ops"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"id": 11}
|
||||
assert calls["create"] == (
|
||||
"Runbook",
|
||||
"line1\\nline2",
|
||||
{"file_type": "md", "notes": "ops", "archived": False, "tags": ["doc", "ops"]},
|
||||
)
|
||||
|
||||
res = await cl.put(
|
||||
"/api/v1/entry/11",
|
||||
json={"content": "line1\\nline2\\nline3", "file_type": "txt"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert calls["modify"][0] == 11
|
||||
assert calls["modify"][1]["content"].endswith("line3")
|
||||
assert calls["modify"][1]["file_type"] == "txt"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_document_import_export_endpoints(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
def import_document(path, **kwargs):
|
||||
calls["import"] = (path, kwargs)
|
||||
return 33
|
||||
|
||||
def export_document(idx, **kwargs):
|
||||
calls["export"] = (idx, kwargs)
|
||||
return Path("/tmp/exported.md")
|
||||
|
||||
api.app.state.pm.entry_manager.import_document_file = import_document
|
||||
api.app.state.pm.entry_manager.export_document_file = export_document
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
imported = await cl.post(
|
||||
"/api/v1/entry/document/import",
|
||||
json={"path": "/tmp/source.md", "label": "Imported", "notes": "n"},
|
||||
headers=headers,
|
||||
)
|
||||
assert imported.status_code == 200
|
||||
assert imported.json() == {"id": 33}
|
||||
assert calls["import"] == (
|
||||
"/tmp/source.md",
|
||||
{"label": "Imported", "notes": "n", "archived": False, "tags": None},
|
||||
)
|
||||
|
||||
exported = await cl.post(
|
||||
"/api/v1/entry/33/document/export",
|
||||
json={"path": "/tmp/out", "overwrite": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert exported.status_code == 200
|
||||
assert exported.json() == {"path": "/tmp/exported.md"}
|
||||
assert calls["export"] == (33, {"output_path": "/tmp/out", "overwrite": True})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_entry_links_endpoints(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
def add_link(entry_id, target_id, **kwargs):
|
||||
calls["add"] = (entry_id, target_id, kwargs)
|
||||
return [{"target_id": target_id, "relation": kwargs["relation"], "note": kwargs["note"]}]
|
||||
|
||||
def remove_link(entry_id, target_id, **kwargs):
|
||||
calls["remove"] = (entry_id, target_id, kwargs)
|
||||
return []
|
||||
|
||||
def get_links(entry_id):
|
||||
calls["get"] = entry_id
|
||||
return [
|
||||
{
|
||||
"target_id": 2,
|
||||
"relation": "references",
|
||||
"note": "used by doc",
|
||||
"target_label": "API Token",
|
||||
"target_kind": "key_value",
|
||||
}
|
||||
]
|
||||
|
||||
api.app.state.pm.entry_manager.add_link = add_link
|
||||
api.app.state.pm.entry_manager.remove_link = remove_link
|
||||
api.app.state.pm.entry_manager.get_links = get_links
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
created = await cl.post(
|
||||
"/api/v1/entry/1/links",
|
||||
json={"target_id": 2, "relation": "references", "note": "used by doc"},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["links"][0]["target_id"] == 2
|
||||
assert calls["add"] == (1, 2, {"relation": "references", "note": "used by doc"})
|
||||
|
||||
listed = await cl.get("/api/v1/entry/1/links", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["entry_id"] == 1
|
||||
assert listed.json()["links"][0]["target_kind"] == "key_value"
|
||||
assert calls["get"] == 1
|
||||
|
||||
removed = await cl.request(
|
||||
"DELETE",
|
||||
"/api/v1/entry/1/links",
|
||||
json={"target_id": 2, "relation": "references"},
|
||||
headers=headers,
|
||||
)
|
||||
assert removed.status_code == 200
|
||||
assert removed.json() == {"entry_id": 1, "links": []}
|
||||
assert calls["remove"] == (1, 2, {"relation": "references"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_entry_error(client):
|
||||
cl, token = client
|
||||
|
||||
@@ -586,6 +586,207 @@ def test_agent_token_issue_and_use_limit(monkeypatch, tmp_path):
|
||||
assert second_payload["reason"] == "token_exhausted"
|
||||
|
||||
|
||||
def test_agent_document_import_with_token_scope(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def import_document_file(self, file_path, **kwargs):
|
||||
assert str(file_path).endswith("notes.md")
|
||||
assert kwargs["label"] == "My Notes"
|
||||
assert kwargs["notes"] == "imported"
|
||||
assert kwargs["tags"] == ["work"]
|
||||
assert kwargs["archived"] is False
|
||||
return 12
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"kind": EntryType.DOCUMENT.value, "label": "My Notes", "file_type": "md"}
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {
|
||||
"version": 1,
|
||||
"default_effect": "deny",
|
||||
"rules": [
|
||||
{
|
||||
"id": "allow_doc_read",
|
||||
"effect": "allow",
|
||||
"operations": ["read"],
|
||||
"kinds": ["document"],
|
||||
"label_regex": ".*",
|
||||
"path_regex": "^entry/.*$",
|
||||
"fields": ["secret"],
|
||||
}
|
||||
],
|
||||
"approvals": {"require_for": []},
|
||||
"output": {"safe_output_default": True, "redact_fields": ["secret"]},
|
||||
"export": {"allow_full_vault": True},
|
||||
"secret_isolation": {"enabled": False, "high_risk_kinds": [], "unlock_ttl_sec": 300},
|
||||
"allow_kinds": ["document"],
|
||||
"deny_private_reveal": [],
|
||||
"allow_export_import": True,
|
||||
},
|
||||
)
|
||||
|
||||
source = tmp_path / "notes.md"
|
||||
source.write_text("# hello\n", encoding="utf-8")
|
||||
|
||||
issue = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"token-issue",
|
||||
"--name",
|
||||
"doc-import",
|
||||
"--scope",
|
||||
"import",
|
||||
"--kind",
|
||||
"document",
|
||||
"--uses",
|
||||
"1",
|
||||
"--ttl",
|
||||
"600",
|
||||
],
|
||||
)
|
||||
assert issue.exit_code == 0
|
||||
token = json.loads(issue.stdout)["token"]
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"ABC123",
|
||||
"agent",
|
||||
"document-import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--label",
|
||||
"My Notes",
|
||||
"--notes",
|
||||
"imported",
|
||||
"--tag",
|
||||
"work",
|
||||
"--token",
|
||||
token,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["index"] == 12
|
||||
assert payload["kind"] == "document"
|
||||
assert payload["file_type"] == "md"
|
||||
assert payload["token_uses_remaining"] == 0
|
||||
|
||||
|
||||
def test_agent_document_export_with_token_scope(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"kind": EntryType.DOCUMENT.value, "label": "Spec"}
|
||||
|
||||
def export_document_file(self, _entry_id, out, **kwargs):
|
||||
assert out == str(tmp_path)
|
||||
assert kwargs["overwrite"] is True
|
||||
return tmp_path / "Spec.md"
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {
|
||||
"version": 1,
|
||||
"default_effect": "deny",
|
||||
"rules": [
|
||||
{
|
||||
"id": "allow_doc_read",
|
||||
"effect": "allow",
|
||||
"operations": ["read"],
|
||||
"kinds": ["document"],
|
||||
"label_regex": ".*",
|
||||
"path_regex": "^entry/.*$",
|
||||
"fields": ["secret"],
|
||||
}
|
||||
],
|
||||
"approvals": {"require_for": []},
|
||||
"output": {"safe_output_default": True, "redact_fields": ["secret"]},
|
||||
"export": {"allow_full_vault": True},
|
||||
"secret_isolation": {"enabled": False, "high_risk_kinds": [], "unlock_ttl_sec": 300},
|
||||
"allow_kinds": ["document"],
|
||||
"deny_private_reveal": [],
|
||||
"allow_export_import": True,
|
||||
},
|
||||
)
|
||||
|
||||
issue = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"token-issue",
|
||||
"--name",
|
||||
"doc-export",
|
||||
"--scope",
|
||||
"export",
|
||||
"--kind",
|
||||
"document",
|
||||
"--uses",
|
||||
"1",
|
||||
"--ttl",
|
||||
"600",
|
||||
],
|
||||
)
|
||||
assert issue.exit_code == 0
|
||||
token = json.loads(issue.stdout)["token"]
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"ABC123",
|
||||
"agent",
|
||||
"document-export",
|
||||
"--entry-id",
|
||||
"4",
|
||||
"--out",
|
||||
str(tmp_path),
|
||||
"--overwrite",
|
||||
"--token",
|
||||
token,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["index"] == 4
|
||||
assert payload["kind"] == "document"
|
||||
assert payload["output_path"] == str(tmp_path / "Spec.md")
|
||||
assert payload["token_uses_remaining"] == 0
|
||||
|
||||
|
||||
def test_agent_identity_create_list_revoke(monkeypatch, tmp_path):
|
||||
import seedpass.core.agent_identity as identity_core
|
||||
|
||||
@@ -1444,6 +1645,7 @@ def test_agent_bootstrap_context(monkeypatch, tmp_path):
|
||||
assert "agent identity-create" in payload["commands"]["identities"]
|
||||
assert "agent high-risk-unlock" in payload["commands"]["secret_isolation"]
|
||||
assert "agent export-check" in payload["commands"]["export_controls"]
|
||||
assert "agent document-export <query>" in payload["commands"]["document_io"]
|
||||
assert "agent posture-check" in payload["commands"]["posture"]
|
||||
assert "agent posture-remediate" in payload["commands"]["posture"]
|
||||
assert "identities" in payload
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
@@ -133,6 +134,14 @@ runner = CliRunner()
|
||||
{"index": 7, "notes": "n"},
|
||||
"8",
|
||||
),
|
||||
(
|
||||
"add-document",
|
||||
"add_document",
|
||||
["Doc", "--content", "hello", "--file-type", "md", "--notes", "n"],
|
||||
("Doc", "hello"),
|
||||
{"file_type": "md", "notes": "n", "tags": None, "archived": False},
|
||||
"9",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_entry_add_commands(
|
||||
@@ -161,3 +170,153 @@ def test_entry_add_commands(
|
||||
assert called["args"] == expected_args
|
||||
assert called["kwargs"] == expected_kwargs
|
||||
assert called.get("sync") is True
|
||||
|
||||
|
||||
def test_entry_import_document_command(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
def import_document_file(path, **kwargs):
|
||||
called["args"] = (path,)
|
||||
called["kwargs"] = kwargs
|
||||
return 123
|
||||
|
||||
def start_background_vault_sync():
|
||||
called["sync"] = True
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(import_document_file=import_document_file),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
source = tmp_path / "doc.md"
|
||||
source.write_text("hello", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"entry",
|
||||
"import-document",
|
||||
"--file",
|
||||
str(source),
|
||||
"--label",
|
||||
"Doc",
|
||||
"--notes",
|
||||
"n",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "123" in result.stdout
|
||||
assert called["args"] == (str(source),)
|
||||
assert called["kwargs"] == {
|
||||
"label": "Doc",
|
||||
"notes": "n",
|
||||
"tags": None,
|
||||
"archived": False,
|
||||
}
|
||||
assert called.get("sync") is True
|
||||
|
||||
|
||||
def test_entry_export_document_command(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def export_document_file(entry_id, output_path=None, **kwargs):
|
||||
called["args"] = (entry_id, output_path)
|
||||
called["kwargs"] = kwargs
|
||||
return Path("/tmp/doc.txt")
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(export_document_file=export_document_file),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
start_background_vault_sync=lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"entry",
|
||||
"export-document",
|
||||
"--entry-id",
|
||||
"9",
|
||||
"--out",
|
||||
"/tmp/outdir",
|
||||
"--overwrite",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "/tmp/doc.txt" in result.stdout
|
||||
assert called["args"] == (9, "/tmp/outdir")
|
||||
assert called["kwargs"] == {"overwrite": True}
|
||||
|
||||
|
||||
def test_entry_link_commands(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def add_link(entry_id, target_id, **kwargs):
|
||||
called["add"] = (entry_id, target_id, kwargs)
|
||||
return [{"target_id": target_id, "relation": kwargs["relation"], "note": kwargs["note"]}]
|
||||
|
||||
def remove_link(entry_id, target_id, **kwargs):
|
||||
called["remove"] = (entry_id, target_id, kwargs)
|
||||
return []
|
||||
|
||||
def get_links(entry_id):
|
||||
called["get"] = entry_id
|
||||
return [{"target_id": 3, "relation": "references", "note": "", "target_label": "X", "target_kind": "document"}]
|
||||
|
||||
def start_background_vault_sync():
|
||||
called["sync"] = called.get("sync", 0) + 1
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
add_link=add_link,
|
||||
remove_link=remove_link,
|
||||
get_links=get_links,
|
||||
),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
|
||||
add_res = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"entry",
|
||||
"link-add",
|
||||
"--entry-id",
|
||||
"1",
|
||||
"--target-id",
|
||||
"3",
|
||||
"--relation",
|
||||
"references",
|
||||
"--note",
|
||||
"note",
|
||||
],
|
||||
)
|
||||
assert add_res.exit_code == 0
|
||||
assert called["add"] == (1, 3, {"relation": "references", "note": "note"})
|
||||
|
||||
list_res = runner.invoke(app, ["entry", "links", "--entry-id", "1"])
|
||||
assert list_res.exit_code == 0
|
||||
assert '"target_id": 3' in list_res.stdout
|
||||
assert called["get"] == 1
|
||||
|
||||
rm_res = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"entry",
|
||||
"link-remove",
|
||||
"--entry-id",
|
||||
"1",
|
||||
"--target-id",
|
||||
"3",
|
||||
"--relation",
|
||||
"references",
|
||||
],
|
||||
)
|
||||
assert rm_res.exit_code == 0
|
||||
assert called["remove"] == (1, 3, {"relation": "references"})
|
||||
@@ -43,6 +43,7 @@ def _make_pm(called, locked=None):
|
||||
handle_add_password=add,
|
||||
handle_add_totp=lambda: None,
|
||||
handle_add_managed_account=lambda: None,
|
||||
handle_add_document=lambda: None,
|
||||
handle_retrieve_entry=retrieve,
|
||||
handle_modify_entry=modify,
|
||||
update_activity=update,
|
||||
@@ -81,7 +82,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
|
||||
def test_invalid_add_entry_submenu(monkeypatch, capsys):
|
||||
called = {"add": False, "retrieve": False, "modify": False}
|
||||
pm, _ = _make_pm(called)
|
||||
inputs = iter(["1", "9", "", ""])
|
||||
inputs = iter(["1", "10", "", ""])
|
||||
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||
with pytest.raises(SystemExit):
|
||||
|
||||
@@ -371,6 +371,12 @@ class TestEntryService:
|
||||
digits=None,
|
||||
key=None,
|
||||
value=None,
|
||||
content=None,
|
||||
file_type=None,
|
||||
custom_fields=None,
|
||||
tags=None,
|
||||
links=None,
|
||||
archived=None,
|
||||
)
|
||||
mock_manager.start_background_vault_sync.assert_called_once()
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import TEST_PASSWORD, TEST_SEED, create_vault
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
|
||||
|
||||
def _make_entry_manager(tmp_path: Path) -> EntryManager:
|
||||
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||
return EntryManager(vault, backup_mgr)
|
||||
|
||||
|
||||
def test_import_document_file_roundtrip():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
entry_mgr = _make_entry_manager(base)
|
||||
source = base / "notes.md"
|
||||
source.write_text("# hello\nworld\n", encoding="utf-8")
|
||||
|
||||
idx = entry_mgr.import_document_file(source, notes="imported", tags=["docs"])
|
||||
entry = entry_mgr.retrieve_entry(idx)
|
||||
assert entry is not None
|
||||
assert entry["kind"] == "document"
|
||||
assert entry["label"] == "notes"
|
||||
assert entry["file_type"] == "md"
|
||||
assert entry["content"] == "# hello\nworld\n"
|
||||
assert entry["notes"] == "imported"
|
||||
assert entry["tags"] == ["docs"]
|
||||
|
||||
|
||||
def test_export_document_file_writes_expected_content():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
entry_mgr = _make_entry_manager(base)
|
||||
idx = entry_mgr.add_document("Runbook", "line1\nline2", file_type="txt")
|
||||
|
||||
out_dir = base / "exports"
|
||||
dest = entry_mgr.export_document_file(idx, output_path=out_dir)
|
||||
assert dest.exists()
|
||||
assert dest.suffix == ".txt"
|
||||
assert dest.read_text(encoding="utf-8") == "line1\nline2"
|
||||
@@ -29,7 +29,7 @@ def test_add_and_retrieve_entry():
|
||||
index = entry_mgr.add_entry("example.com", 12, "user", custom_fields=custom)
|
||||
entry = entry_mgr.retrieve_entry(index)
|
||||
|
||||
assert entry == {
|
||||
expected = {
|
||||
"label": "example.com",
|
||||
"length": 12,
|
||||
"username": "user",
|
||||
@@ -41,6 +41,9 @@ def test_add_and_retrieve_entry():
|
||||
"custom_fields": custom,
|
||||
"tags": [],
|
||||
}
|
||||
assert entry.items() >= expected.items()
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||
assert str(index) in data.get("entries", {})
|
||||
@@ -58,6 +61,7 @@ def test_add_and_retrieve_entry():
|
||||
("add_seed", "seed"),
|
||||
("add_key_value", "key_value"),
|
||||
("add_managed_account", "managed_account"),
|
||||
("add_document", "document"),
|
||||
],
|
||||
)
|
||||
def test_round_trip_entry_types(method, expected_type):
|
||||
@@ -74,6 +78,8 @@ def test_round_trip_entry_types(method, expected_type):
|
||||
index = 0
|
||||
elif method == "add_key_value":
|
||||
index = entry_mgr.add_key_value("label", "k1", "val")
|
||||
elif method == "add_document":
|
||||
index = entry_mgr.add_document("doc", "hello", file_type="txt")
|
||||
else:
|
||||
if method == "add_ssh_key":
|
||||
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
|
||||
@@ -87,6 +93,8 @@ def test_round_trip_entry_types(method, expected_type):
|
||||
entry = entry_mgr.retrieve_entry(index)
|
||||
assert entry["type"] == expected_type
|
||||
assert entry["kind"] == expected_type
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||
assert data["entries"][str(index)]["type"] == expected_type
|
||||
assert data["entries"][str(index)]["kind"] == expected_type
|
||||
@@ -120,6 +128,7 @@ def test_legacy_entry_defaults_to_password():
|
||||
("add_seed", ("seed", TEST_SEED)),
|
||||
("add_key_value", ("label", "k1", "val")),
|
||||
("add_managed_account", ("acct", TEST_SEED)),
|
||||
("add_document", ("doc", "body")),
|
||||
],
|
||||
)
|
||||
def test_add_default_archived_false(method, args):
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_PASSWORD, TEST_SEED
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
|
||||
|
||||
def _entry_manager(tmp_path: Path) -> EntryManager:
|
||||
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||
return EntryManager(vault, backup_mgr)
|
||||
|
||||
|
||||
def test_links_add_list_remove_and_search():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
em = _entry_manager(Path(tmpdir))
|
||||
src = em.add_document("Project Plan", "v1", file_type="md")
|
||||
dst = em.add_key_value("API Token", "token", "abc")
|
||||
|
||||
links = em.add_link(src, dst, relation="references", note="used by deploy")
|
||||
assert links == [
|
||||
{"target_id": dst, "relation": "references", "note": "used by deploy"}
|
||||
]
|
||||
|
||||
resolved = em.get_links(src)
|
||||
assert resolved[0]["target_id"] == dst
|
||||
assert resolved[0]["relation"] == "references"
|
||||
assert resolved[0]["target_label"] == "API Token"
|
||||
assert resolved[0]["target_kind"] == "key_value"
|
||||
|
||||
# Query by relation, note, and target label should match source.
|
||||
assert em.search_entries("references")[0][0] == src
|
||||
assert em.search_entries("deploy")[0][0] == src
|
||||
assert em.search_entries("api token")[0][0] == src
|
||||
|
||||
remaining = em.remove_link(src, dst, relation="references")
|
||||
assert remaining == []
|
||||
assert em.get_links(src) == []
|
||||
|
||||
|
||||
def test_links_reject_invalid_targets_and_self_link():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
em = _entry_manager(Path(tmpdir))
|
||||
idx = em.add_entry("example", 12)
|
||||
|
||||
try:
|
||||
em.add_link(idx, idx)
|
||||
assert False, "Expected self-link validation failure"
|
||||
except ValueError as exc:
|
||||
assert "Self-referential" in str(exc)
|
||||
|
||||
try:
|
||||
em.add_link(idx, 9999)
|
||||
assert False, "Expected missing target validation failure"
|
||||
except ValueError as exc:
|
||||
assert "Target entry not found" in str(exc)
|
||||
@@ -68,6 +68,14 @@ def _add_entry_for_type(entry_mgr: EntryManager, entry_type: EntryType) -> int:
|
||||
return entry_mgr.add_managed_account(
|
||||
"Managed Entry", TEST_SEED, notes="managed-note", tags=["managed"]
|
||||
)
|
||||
if entry_type == EntryType.DOCUMENT:
|
||||
return entry_mgr.add_document(
|
||||
"Document Entry",
|
||||
"alpha\nbeta\ngamma",
|
||||
file_type="md",
|
||||
notes="doc-note",
|
||||
tags=["doc"],
|
||||
)
|
||||
raise AssertionError(f"Unhandled entry type in test: {entry_type}")
|
||||
|
||||
|
||||
@@ -86,6 +94,8 @@ def test_each_entry_type_roundtrip_retrieve_and_filter(tmp_path, entry_type: Ent
|
||||
assert entry["kind"] == entry_type.value
|
||||
assert entry["label"]
|
||||
assert "modified_ts" not in entry
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
entries = entry_mgr.list_entries(
|
||||
filter_kinds=[entry_type.value], include_archived=True, verbose=False
|
||||
@@ -126,3 +136,26 @@ def test_each_entry_type_roundtrip_retrieve_and_filter(tmp_path, entry_type: Ent
|
||||
if entry_type == EntryType.MANAGED_ACCOUNT:
|
||||
assert entry["word_count"] == 12
|
||||
assert entry["fingerprint"]
|
||||
if entry_type == EntryType.DOCUMENT:
|
||||
assert entry["file_type"] == "md"
|
||||
assert "alpha" in entry["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry_type", list(EntryType))
|
||||
def test_each_entry_type_sets_and_updates_dates(
|
||||
tmp_path, monkeypatch, entry_type: EntryType
|
||||
):
|
||||
entry_mgr = _make_entry_manager(tmp_path)
|
||||
monkeypatch.setattr(entry_mgr, "_now_unix", lambda: 100)
|
||||
index = _add_entry_for_type(entry_mgr, entry_type)
|
||||
created = entry_mgr.retrieve_entry(index)
|
||||
assert created is not None
|
||||
assert created["date_added"] == entry_mgr._iso_from_unix(100)
|
||||
assert created["date_modified"] == entry_mgr._iso_from_unix(100)
|
||||
|
||||
monkeypatch.setattr(entry_mgr, "_now_unix", lambda: 200)
|
||||
entry_mgr.modify_entry(index, label=f"{created['label']} updated")
|
||||
updated = entry_mgr.retrieve_entry(index)
|
||||
assert updated is not None
|
||||
assert updated["date_added"] == entry_mgr._iso_from_unix(100)
|
||||
assert updated["date_modified"] == entry_mgr._iso_from_unix(200)
|
||||
@@ -64,3 +64,35 @@ def test_full_sync_roundtrip(dummy_nostr_client):
|
||||
pm_b.entry_manager.clear_cache()
|
||||
labels = [e[1] for e in pm_b.entry_manager.list_entries()]
|
||||
assert sorted(labels) == ["site1", "site2"]
|
||||
|
||||
|
||||
def test_large_document_sync_roundtrip(dummy_nostr_client):
|
||||
client, relay = dummy_nostr_client
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
dir_a = base / "A"
|
||||
dir_b = base / "B"
|
||||
dir_a.mkdir()
|
||||
dir_b.mkdir()
|
||||
|
||||
pm_a = _init_pm(dir_a, client)
|
||||
pm_b = _init_pm(dir_b, client)
|
||||
|
||||
large_content = "line\n" * 120_000
|
||||
doc_id = pm_a.entry_manager.add_document(
|
||||
"LargeDoc",
|
||||
large_content,
|
||||
file_type="txt",
|
||||
notes="large-payload",
|
||||
tags=["sync", "large"],
|
||||
)
|
||||
|
||||
pm_a.sync_vault()
|
||||
|
||||
result = pm_b.attempt_initial_sync()
|
||||
assert result is True
|
||||
restored = pm_b.entry_manager.retrieve_entry(doc_id)
|
||||
assert restored is not None
|
||||
assert restored["kind"] == "document"
|
||||
assert restored["file_type"] == "txt"
|
||||
assert restored["content"] == large_content
|
||||
@@ -25,17 +25,17 @@ def test_add_and_modify_key_value():
|
||||
|
||||
idx = em.add_key_value("API entry", "api_key", "abc123", notes="token")
|
||||
entry = em.retrieve_entry(idx)
|
||||
assert entry == {
|
||||
"type": "key_value",
|
||||
"kind": "key_value",
|
||||
"label": "API entry",
|
||||
"key": "api_key",
|
||||
"value": "abc123",
|
||||
"notes": "token",
|
||||
"archived": False,
|
||||
"custom_fields": [],
|
||||
"tags": [],
|
||||
}
|
||||
assert entry["type"] == "key_value"
|
||||
assert entry["kind"] == "key_value"
|
||||
assert entry["label"] == "API entry"
|
||||
assert entry["key"] == "api_key"
|
||||
assert entry["value"] == "abc123"
|
||||
assert entry["notes"] == "token"
|
||||
assert entry["archived"] is False
|
||||
assert entry["custom_fields"] == []
|
||||
assert entry["tags"] == []
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
# Appears in listing
|
||||
assert em.list_entries() == [(idx, "API entry", None, None, False)]
|
||||
|
||||
@@ -49,9 +49,11 @@ def test_migrate_iterations(tmp_path, monkeypatch, iterations):
|
||||
calls: list[int] = []
|
||||
orig_derive = enc_module._derive_legacy_key_from_password
|
||||
|
||||
def tracking_derive(password: str, iterations: int = 100_000) -> bytes:
|
||||
def tracking_derive(
|
||||
password: str, iterations: int = 100_000, salt: bytes = b""
|
||||
) -> bytes:
|
||||
calls.append(iterations)
|
||||
return orig_derive(password, iterations=iterations)
|
||||
return orig_derive(password, iterations=iterations, salt=salt)
|
||||
|
||||
monkeypatch.setattr(enc_module, "_derive_legacy_key_from_password", tracking_derive)
|
||||
|
||||
@@ -60,7 +62,11 @@ def test_migrate_iterations(tmp_path, monkeypatch, iterations):
|
||||
vault.load_index()
|
||||
|
||||
assert prompts == [1]
|
||||
expected = [50_000] if iterations == 50_000 else [50_000, 100_000]
|
||||
expected = (
|
||||
[50_000, 100_000, 50_000]
|
||||
if iterations == 50_000
|
||||
else [50_000, 100_000, 50_000, 100_000]
|
||||
)
|
||||
assert calls == expected
|
||||
|
||||
cfg = ConfigManager(vault, tmp_path)
|
||||
|
||||
@@ -40,8 +40,8 @@ def test_sort_by_updated():
|
||||
idx1 = em.add_entry("beta.com", 8, "u1")
|
||||
|
||||
data = em._load_index(force_reload=True)
|
||||
data["entries"][str(idx0)]["updated"] = 1
|
||||
data["entries"][str(idx1)]["updated"] = 2
|
||||
data["entries"][str(idx0)]["modified_ts"] = 1
|
||||
data["entries"][str(idx1)]["modified_ts"] = 2
|
||||
em._save_index(data)
|
||||
|
||||
result = em.list_entries(sort_by="updated")
|
||||
|
||||
@@ -74,18 +74,18 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
|
||||
assert entries == [(0, "Example", "", "", False)]
|
||||
|
||||
entry = entry_mgr.retrieve_entry(0)
|
||||
assert entry == {
|
||||
"label": "Example",
|
||||
"length": DEFAULT_PASSWORD_LENGTH,
|
||||
"username": "",
|
||||
"url": "",
|
||||
"archived": False,
|
||||
"type": "password",
|
||||
"kind": "password",
|
||||
"notes": "",
|
||||
"custom_fields": [],
|
||||
"tags": [],
|
||||
}
|
||||
assert entry["label"] == "Example"
|
||||
assert entry["length"] == DEFAULT_PASSWORD_LENGTH
|
||||
assert entry["username"] == ""
|
||||
assert entry["url"] == ""
|
||||
assert entry["archived"] is False
|
||||
assert entry["type"] == "password"
|
||||
assert entry["kind"] == "password"
|
||||
assert entry["notes"] == ""
|
||||
assert entry["custom_fields"] == []
|
||||
assert entry["tags"] == []
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
|
||||
|
||||
@@ -194,18 +194,18 @@ def test_handle_add_password_quick_mode(monkeypatch, dummy_nostr_client, capsys)
|
||||
assert entries == [(0, "Example", "", "", False)]
|
||||
|
||||
entry = entry_mgr.retrieve_entry(0)
|
||||
assert entry == {
|
||||
"label": "Example",
|
||||
"length": DEFAULT_PASSWORD_LENGTH,
|
||||
"username": "",
|
||||
"url": "",
|
||||
"archived": False,
|
||||
"type": "password",
|
||||
"kind": "password",
|
||||
"notes": "",
|
||||
"custom_fields": [],
|
||||
"tags": [],
|
||||
}
|
||||
assert entry["label"] == "Example"
|
||||
assert entry["length"] == DEFAULT_PASSWORD_LENGTH
|
||||
assert entry["username"] == ""
|
||||
assert entry["url"] == ""
|
||||
assert entry["archived"] is False
|
||||
assert entry["type"] == "password"
|
||||
assert entry["kind"] == "password"
|
||||
assert entry["notes"] == ""
|
||||
assert entry["custom_fields"] == []
|
||||
assert entry["tags"] == []
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ def entry_manager():
|
||||
{"url": "seed_url"},
|
||||
"does not support fields: url",
|
||||
),
|
||||
(
|
||||
EntryType.DOCUMENT,
|
||||
lambda em: em.add_document("Doc", "body"),
|
||||
{"username": "doc_user"},
|
||||
"does not support fields: username",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_modify_entry_invalid_fields(
|
||||
|
||||
@@ -24,15 +24,15 @@ def test_nostr_key_determinism():
|
||||
|
||||
idx = entry_mgr.add_nostr_key("main", TEST_SEED)
|
||||
entry = entry_mgr.retrieve_entry(idx)
|
||||
assert entry == {
|
||||
"type": "nostr",
|
||||
"kind": "nostr",
|
||||
"index": idx,
|
||||
"label": "main",
|
||||
"notes": "",
|
||||
"archived": False,
|
||||
"tags": [],
|
||||
}
|
||||
assert entry["type"] == "nostr"
|
||||
assert entry["kind"] == "nostr"
|
||||
assert entry["index"] == idx
|
||||
assert entry["label"] == "main"
|
||||
assert entry["notes"] == ""
|
||||
assert entry["archived"] is False
|
||||
assert entry["tags"] == []
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
|
||||
npub2, nsec2 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
|
||||
|
||||
@@ -12,10 +12,12 @@ import seedpass.core.encryption as enc_module
|
||||
def test_legacy_password_only_fallback(monkeypatch, tmp_path, caplog):
|
||||
calls: list[int] = []
|
||||
|
||||
def _fast_legacy_key(password: str, iterations: int = 100_000) -> bytes:
|
||||
def _fast_legacy_key(
|
||||
password: str, iterations: int = 100_000, salt: bytes = b""
|
||||
) -> bytes:
|
||||
calls.append(iterations)
|
||||
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
|
||||
key = hashlib.pbkdf2_hmac("sha256", normalized, b"", 1, dklen=32)
|
||||
key = hashlib.pbkdf2_hmac("sha256", normalized, salt, 1, dklen=32)
|
||||
return base64.urlsafe_b64encode(key)
|
||||
|
||||
# Speed up legacy key derivation
|
||||
@@ -35,4 +37,4 @@ def test_legacy_password_only_fallback(monkeypatch, tmp_path, caplog):
|
||||
assert enc_mgr.decrypt_and_save_index_from_nostr(encrypted)
|
||||
assert vault.load_index() == data
|
||||
assert any("legacy password-only" in rec.message for rec in caplog.records)
|
||||
assert calls == [50_000, 50_000]
|
||||
assert calls == [50_000, 50_000, 100_000, 50_000]
|
||||
@@ -34,27 +34,27 @@ def test_seed_phrase_determinism():
|
||||
entry12 = entry_mgr.retrieve_entry(idx_12)
|
||||
entry24 = entry_mgr.retrieve_entry(idx_24)
|
||||
|
||||
assert entry12 == {
|
||||
"type": "seed",
|
||||
"kind": "seed",
|
||||
"index": idx_12,
|
||||
"label": "seed12",
|
||||
"word_count": 12,
|
||||
"notes": "",
|
||||
"archived": False,
|
||||
"tags": [],
|
||||
}
|
||||
assert entry12["type"] == "seed"
|
||||
assert entry12["kind"] == "seed"
|
||||
assert entry12["index"] == idx_12
|
||||
assert entry12["label"] == "seed12"
|
||||
assert entry12["word_count"] == 12
|
||||
assert entry12["notes"] == ""
|
||||
assert entry12["archived"] is False
|
||||
assert entry12["tags"] == []
|
||||
assert "date_added" in entry12
|
||||
assert "date_modified" in entry12
|
||||
|
||||
assert entry24 == {
|
||||
"type": "seed",
|
||||
"kind": "seed",
|
||||
"index": idx_24,
|
||||
"label": "seed24",
|
||||
"word_count": 24,
|
||||
"notes": "",
|
||||
"archived": False,
|
||||
"tags": [],
|
||||
}
|
||||
assert entry24["type"] == "seed"
|
||||
assert entry24["kind"] == "seed"
|
||||
assert entry24["index"] == idx_24
|
||||
assert entry24["label"] == "seed24"
|
||||
assert entry24["word_count"] == 24
|
||||
assert entry24["notes"] == ""
|
||||
assert entry24["archived"] is False
|
||||
assert entry24["tags"] == []
|
||||
assert "date_added" in entry24
|
||||
assert "date_modified" in entry24
|
||||
|
||||
assert phrase12_a not in entry12.values()
|
||||
assert phrase24_a not in entry24.values()
|
||||
|
||||
@@ -22,15 +22,16 @@ def test_add_and_retrieve_ssh_key_pair():
|
||||
|
||||
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
|
||||
entry = entry_mgr.retrieve_entry(index)
|
||||
assert entry == {
|
||||
"type": "ssh",
|
||||
"kind": "ssh",
|
||||
"index": index,
|
||||
"label": "ssh",
|
||||
"notes": "",
|
||||
"archived": False,
|
||||
"tags": [],
|
||||
}
|
||||
assert entry["type"] == "ssh"
|
||||
assert entry["kind"] == "ssh"
|
||||
assert entry["index"] == index
|
||||
assert entry["label"] == "ssh"
|
||||
assert entry["notes"] == ""
|
||||
assert entry["archived"] is False
|
||||
assert entry["tags"] == []
|
||||
assert entry["custom_fields"] == []
|
||||
assert "date_added" in entry
|
||||
assert "date_modified" in entry
|
||||
|
||||
priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)
|
||||
priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)
|
||||
|
||||
Reference in new issue
Block a user