Skip to content

feat: protect manually-edited Bitwarden items on re-run (#30)#31

Merged
kjanat merged 3 commits into
masterfrom
feat/protect-manual-edits
Jun 14, 2026
Merged

feat: protect manually-edited Bitwarden items on re-run (#30)#31
kjanat merged 3 commits into
masterfrom
feat/protect-manual-edits

Conversation

@kjanat

@kjanat kjanat commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Closes #30.

Problem

kp2bw is KeePass-authoritative: _reconcile_existing_item overwrote any difference on an existing item with the KeePass value, silently reverting manual edits made in Bitwarden (a fixed title, an added note, a corrected URI). The only escape was --no-update, which freezes every item.

Approach — content signature, not a timestamp

Every item kp2bw writes carries a KP2BW_SYNC plain-text field holding _content_signature(item) — a sha256 over exactly the surface _content_differs compares (name, notes, custom fields with managed stamps excluded, login creds/totp/uris). On re-run, when content differs, _is_user_modified(existing) checks stamp != _content_signature(existing):

  • stamp matches → kp2bw's own last write is intact; the diff is KeePass-side → update (normal behaviour).
  • stamp differs → a user edited it in Bitwarden → protect (skip the PUT, preserve the edit), reported as protected in the summary.
  • no stamp (legacy / first run) → not protected; the write establishes the stamp.

--force-update (env KP2BW_FORCE_UPDATE) makes KeePass win regardless.

Why not revisionDate?

The issue's "catch" warns kp2bw's own writes bump revisionDate. A client-stamped timestamp compared against the server's revisionDate would false-trip every kp2bw-written item, because the server sets revisionDate after kp2bw generates the stamp. The content signature sidesteps all clock/timezone/race issues, and _content_differs + the stamp share _content_signature so protection can never drift from the diff definition.

Idempotency / safety

  • KP2BW_SYNC is in _MANAGED_FIELD_NAMES, excluded from the content signature → unchanged re-runs stay skipped, never protected.
  • Plain text, type=0 (not "hidden") — like KP2BW_ID it's metadata, not a secret, and a hidden field is only UI-masked. The digest leaks nothing not already in cleartext on the same item.
  • --strip-ids now removes KP2BW_SYNC alongside KP2BW_ID (strip_field_from_items(*field_names)).
  • Snapshot normalizer drops KP2BW_SYNC too, so e2e goldens stay deterministic.

Tests

tests/protect_edits_test.py covers the full matrix: signature excludes the stamp; legacy/own-write not modified; user edit detected; protected vs --force-update; KeePass-only change updates; matching content skips.

Gates: ruff ✓, ty ✓, basedpyright 0/0/0, 18 passed/4 skipped, dprint ✓.

kp2bw is KeePass-authoritative, so a re-run overwrote any difference on an
existing item -- silently reverting edits made in Bitwarden (a fixed title,
added note, corrected URI). Now every written item carries a KP2BW_SYNC
plain-text content signature; a re-run that finds an item's managed content
no longer matching the stamp knows a user edited it (kp2bw's own writes
restamp, so they never self-trip) and preserves the edit, reporting it as
'protected' instead of clobbering. --force-update (KP2BW_FORCE_UPDATE) makes
KeePass win regardless.

Signature mechanism, not a revisionDate/timestamp compare: the server bumps
revisionDate after kp2bw generates a stamp, so a timestamp compare would
false-trip every kp2bw-written item. _content_differs and the stamp share
_content_signature, so protection can't drift from the diff definition.

- bw_serve: KP2BW_SYNC_FIELD_NAME + item_kp2bw_sync; strip_field_from_items
  takes *field_names so --strip-ids drops KP2BW_SYNC alongside KP2BW_ID.
- convert: _content_signature/_login_signature/_is_user_modified; stamp on
  write; force_update flag; 'protected' outcome + summary counter.
- snapshot normalizer drops KP2BW_SYNC too (goldens stay deterministic).
- tests/protect_edits_test.py covers the full decision matrix.
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown

📦 Test this PR (archived)

Status: ✅ Merged

This PR has been merged. You can still test the final state:

uvx -p 3.14 --from 'git+https://github.com/kjanat/kp2bw@aa80c2cb3441a306321d170e0dd3a176d84b11ef' kp2bw --help

📋 PR Details

Field Value
Final commit aa80c2c
Commit message feat(convert): protect manually-edited Bitwarden items on re-run (#30)
Branch feat/protect-manual-edits

Merge commit: aa80c2c

📋 Example usage
# Show version
uvx -p 3.14 --from 'git+https://github.com/kjanat/kp2bw@aa80c2cb3441a306321d170e0dd3a176d84b11ef' kp2bw --version
# Migrate KeePass DB (interactive password prompts)
uvx -p 3.14 --from 'git+https://github.com/kjanat/kp2bw@aa80c2cb3441a306321d170e0dd3a176d84b11ef' kp2bw vault.kdbx
# Non-interactive with env vars
KP2BW_KEEPASS_PASSWORD=kppass KP2BW_BITWARDEN_PASSWORD=bwpass uvx -p 3.14 --from 'git+https://github.com/kjanat/kp2bw@aa80c2cb3441a306321d170e0dd3a176d84b11ef' kp2bw vault.kdbx -y

🤖 Archived on merge

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • cr:review
🚫 Excluded labels (none allowed) (2)
  • wip
  • cr:skip

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 82dec599-2939-4899-9cc1-4d5b8bb13459

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/kp2bw/convert.py Dismissed
CodeQL py/clear-text-logging-sensitive-data flagged 10 sites (convert +
bw_serve) after the protection stamp landed. Root cause: _content_signature
did repr((... _login_signature(login) ...)), materialising the cleartext
password into a string; CodeQL then classed the whole item dict as
clear-text-sensitive and flagged every read of it (name, item_id,
attachment_id, even bw_serve request internals) -- none of which actually
log the password.

Fix at the source: _login_signature now incorporates the password as its own
SHA-256 digest, never in clear text. A password edited in Bitwarden still
flips the digest (detection unchanged, deterministic), but no cleartext
credential enters the signature string or the stored stamp -- the stamp is
derived from a password digest. Defense-in-depth bonus: the KP2BW_SYNC value
no longer fingerprints the raw password.
Comment thread src/kp2bw/convert.py Fixed
The digest attempt did not clear the clear-text-logging alerts (CodeQL taints
through the hash -- it is the password-derived KP2BW_SYNC stamp *stored on the
item*, not its cleartext-ness, that marks the dict sensitive) and it added a
new py/weak-sensitive-data-hashing alert (sha256 flagged as weak password
hashing). Back to comparing the raw password value, which is only used for the
content fingerprint and equality checks, never logged.
@kjanat kjanat self-assigned this Jun 14, 2026
@kjanat kjanat merged commit aa80c2c into master Jun 14, 2026
14 checks passed
@kjanat kjanat deleted the feat/protect-manual-edits branch June 14, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Protect manually-edited Bitwarden items from being overwritten on re-migration

2 participants