diff --git a/1password/migration/keepass/README.md b/1password/migration/keepass/README.md index 9dd7c19..fbdc6f4 100644 --- a/1password/migration/keepass/README.md +++ b/1password/migration/keepass/README.md @@ -1,24 +1,24 @@ # KeePass XML → 1Password Importer -Migrates a KeePass XML 2.x export into 1Password using the [onepassword-sdk](https://github.com/1Password/onepassword-sdk-python) bulk-create API. +Migrates a KeePass XML 2.x export into a single 1Password vault using the [onepassword-sdk](https://github.com/1Password/onepassword-sdk-python) bulk-create API. The vault is created by the service account if it does not already exist. ## Features -- **Password history** — each entry's historical passwords are appended to the item's Notes field (newest first, duplicates suppressed) -- **Attachments** — binary attachments from the KeePass pool are imported as 1Password file attachments +- **Single vault** — all entries land in one vault; the service account creates it if needed +- **Group → tag mapping** — KeePass group paths become tags, with one tag per level of hierarchy +- **Password history** — KeePass history is replayed as real 1Password item history (viewable via "View password history" in the UI) +- **Attachments** — binary attachments are imported as 1Password file attachments - **TOTP** — `otpauth://` URIs are mapped to a proper OTP field -- **Group → Vault mapping** — KeePass groups become 1Password vaults; sub-groups either get their own vault or collapse into the parent as tags (see `--collapse-folders`) - **Recycle Bin** — skipped automatically - **Resumable** — a state file is written on interruption (e.g. rate limit); re-running the same command picks up where it left off ## Requirements -| Requirement | Notes | -| -------------------------- | ------------------------------------------------------------ | -| Python 3.9+ | | -| `OP_SERVICE_ACCOUNT_TOKEN` | Set as an environment variable | -| `onepassword-sdk` | Auto-installed into `.venv-1pw` if missing | -| `op` CLI | Only needed for `--user-for-private` vault permission grants | +| Requirement | Notes | +| -------------------------- | ------------------------------------------ | +| Python 3.9+ | | +| `OP_SERVICE_ACCOUNT_TOKEN` | Set as an environment variable | +| `onepassword-sdk` | Auto-installed into `.venv-1pw` if missing | ## Usage @@ -28,71 +28,44 @@ export OP_SERVICE_ACCOUNT_TOKEN=your-token-here # Preview — no changes made python import-from-keepass-xml.py \ --input keepass-export.xml \ - --employee-vault "KeePass Import" \ + --vault "KeePass Import" \ --dry-run # Live import python import-from-keepass-xml.py \ --input keepass-export.xml \ - --employee-vault "KeePass Import" - -# Collapse sub-groups into parent vaults (sub-groups become tags) -python import-from-keepass-xml.py \ - --input keepass-export.xml \ - --employee-vault "KeePass Import" \ - --collapse-folders - -# Grant a user edit access to all imported vaults -python import-from-keepass-xml.py \ - --input keepass-export.xml \ - --employee-vault "KeePass Import" \ - --user-for-private user@example.com + --vault "KeePass Import" ``` ## Options -| Flag | Default | Description | -| -------------------- | ------------ | ------------------------------------------------------------- | -| `--input` | _(required)_ | Path to KeePass XML 2.x export | -| `--employee-vault` | _(required)_ | Fallback vault for entries with no group | -| `--private-prefix` | `""` | String prepended to vault names derived from groups | -| `--collapse-folders` | off | Collapse sub-groups into parent vault; sub-groups become tags | -| `--user-for-private` | — | Grant this user (email) edit access to all vaults | -| `--dry-run` | off | Print planned actions without creating anything | -| `--silent` | off | Suppress progress output | +| Flag | Description | +| ----------- | ---------------------------------------------------------------- | +| `--input` | Path to KeePass XML 2.x export _(required)_ | +| `--vault` | Destination vault name; created if it doesn't exist _(required)_ | +| `--dry-run` | Print planned actions without creating anything | +| `--silent` | Suppress progress output | -## Vault mapping +## Tag mapping -Without `--collapse-folders`, each unique group path becomes its own vault: +KeePass group paths are converted to tags, with a tag added for each level of the hierarchy so items remain discoverable by parent group: ``` -Email → vault "Email" -Email/Work → vault "Email/Work" -Development → vault "Development" -Development/Cloud → vault "Development/Cloud" +(no group) → no tags +Email → ["Email"] +Email/Work → ["Email", "Email/Work"] +Development/Cloud → ["Development", "Development/Cloud"] ``` -With `--collapse-folders`, only the top-level group is a vault; deeper paths become tags: +## Password history -``` -Email → vault "Email", tag "Email" -Email/Work → vault "Email", tag "Email\Work" -Development/Cloud → vault "Development", tag "Development\Cloud" -``` - -Entries with no group go to the `--employee-vault`. - -## Password history format +KeePass password history is replayed as real 1Password item history, visible in the UI via **View password history**: -Historical passwords are appended to the item's Notes field: - -``` ---- Password History --- -2024-01-15T09:23:00Z previous-password -2022-06-01T14:00:00Z older-password -``` +1. The item is created with the **oldest** historical password. +2. The item is updated (`put()`) once per subsequent password, oldest → newest. +3. A final `put()` restores the **current** password. -The current password is never repeated in the history block. Exact duplicate historical passwords are suppressed. +Each `put()` call produces a genuine 1Password history entry. Items without history are bulk-created normally (no extra API calls). Timestamps from KeePass are not preserved — history entries will show the time the import ran. ## Resuming an interrupted import @@ -105,6 +78,6 @@ A test XML file (`keepass-test-export.xml`) is provided. It covers logins, secur ```bash python import-from-keepass-xml.py \ --input keepass-test-export.xml \ - --employee-vault "KeePass Import" \ + --vault "KeePass Import" \ --dry-run ``` diff --git a/1password/migration/keepass/TestDatabase.xml b/1password/migration/keepass/TestDatabase.xml new file mode 100644 index 0000000..ddc1ff2 --- /dev/null +++ b/1password/migration/keepass/TestDatabase.xml @@ -0,0 +1,1299 @@ + + + + KeePass + 2026-06-16T14:29:35Z + TestDatabase + 2026-06-16T14:29:35Z + This is a test database to provide test/sample data of a keepass export + 2026-06-16T14:29:35Z + + 2026-06-16T14:28:22Z + 365 + + 2026-06-16T14:28:22Z + -1 + -1 + + False + False + True + False + False + + True + k702N2RWGk6wDtw3RHADUA== + 2026-06-16T14:28:22Z + AAAAAAAAAAAAAAAAAAAAAA== + 2026-06-16T14:28:22Z + 10 + 6291456 + jN/K/nSRM0qNnq7W20TtTg== + 7DaGTBCYPUWr1f5xmU3h5g== + + + + + + 7DaGTBCYPUWr1f5xmU3h5g== + TestDatabase + + 49 + + 2026-06-16T14:28:22Z + 2026-06-16T14:28:22Z + 2026-06-16T15:20:18Z + 2026-06-03T13:08:10Z + False + 55 + 2026-06-16T14:28:22Z + + True + + Null + Null + /56qzhmfFE6DGyzDi2ulqQ== + + /56qzhmfFE6DGyzDi2ulqQ== + 0 + + + + + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-03T13:08:10Z + False + 0 + 2026-06-16T14:29:35Z + + + Notes + Notes + + + Password + Password + + + Title + Sample Entry + + + URL + https://keepass.info/ + + + UserName + User Name + + + True + 0 + + Target Window + {USERNAME}{TAB}{PASSWORD}{TAB}{ENTER} + + + + + + O7bys2y1IU+nUtPiD0MmOg== + 0 + + + + + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-03T13:08:10Z + False + 0 + 2026-06-16T14:29:35Z + + + Password + 12345 + + + Title + Sample Entry #2 + + + URL + https://keepass.info/help/kb/testform.html + + + UserName + Michael321 + + + True + 0 + + *Test Form - KeePass* + + + + + + + k702N2RWGk6wDtw3RHADUA== + Recycle Bin + + 43 + + 2026-06-16T14:31:36Z + 2026-06-16T14:31:36Z + 2026-06-16T15:15:55Z + 2026-06-03T13:08:10Z + False + 7 + 2026-06-16T14:31:36Z + + False + + False + False + AAAAAAAAAAAAAAAAAAAAAA== + + 3zb/pabKWkmEeL/izyDVwg== + 0 + + + + + + 2026-06-16T15:15:24Z + 2026-06-16T15:15:50Z + 2026-06-16T15:15:55Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T15:15:55Z + + + Notes + + + + Password + WuNgQly6ZaLGX2BrwBCt + + + Title + Test secret test equipment + + + URL + + + + UserName + test.user2 + + + True + 0 + + + + + ++n1F/0kiUCOhAuWaEuhFw== + General + + 48 + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:31:36Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:31:36Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + + 4o0sdQBHfUucE/5/kTCrWg== + Windows + + 38 + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:31:48Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:31:48Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + + CdKlK4LLpUCDkeQ0ecfH0w== + Network + + 3 + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:32:41Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:32:41Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + + xIReP4+wx0exIIvtZjoYOw== + Internet + + 1 + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:32:44Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:32:44Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + + fn2Sea6Gyki2Wq68c3pDdQ== + eMail + + 19 + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:32:48Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:32:48Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + + 9E9FN7+tFEG0d9o4Gibflw== + Homebanking + + 37 + + 2026-06-16T14:29:35Z + 2026-06-16T14:29:35Z + 2026-06-16T14:32:52Z + 2026-06-03T13:08:10Z + False + 3 + 2026-06-16T14:32:52Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + + + AVSR521hIU6aQgsBDpsLHw== + Digital Radio + + 48 + + 2026-06-16T14:31:51Z + 2026-06-16T14:31:59Z + 2026-06-16T15:18:56Z + 2026-06-15T23:00:00Z + False + 13 + 2026-06-16T14:31:51Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + p/1clFGvQ0yC3QDR8MduEQ== + Mier + + 48 + + 2026-06-16T14:32:16Z + 2026-06-16T14:32:21Z + 2026-06-16T15:16:44Z + 2026-06-15T23:00:00Z + False + 5 + 2026-06-16T14:32:16Z + + True + + Null + Null + iPGUG7OkvUmTgbC86cl/3g== + + iPGUG7OkvUmTgbC86cl/3g== + 0 + + + + + + 2026-06-16T14:36:16Z + 2026-06-16T15:17:26Z + 2026-06-16T15:18:01Z + 2026-06-03T13:08:10Z + False + 6 + 2026-06-16T14:36:16Z + + + Notes + This is a test +Mier 1 + + + Password + Password4! + + + Title + Test Secret Mier 1 + + + URL + www.google.com + + + UserName + Test.user + + + True + 0 + + + + iPGUG7OkvUmTgbC86cl/3g== + 0 + + + + + + 2026-06-16T14:36:16Z + 2026-06-16T14:38:13Z + 2026-06-16T15:17:51Z + 2026-06-03T13:08:10Z + False + 3 + 2026-06-16T14:36:16Z + + + Notes + This is a test +Mier 1 + + + Password + Password1! + + + Title + Test Secret Mier 1 + + + URL + www.google.com + + + UserName + Test.user + + + True + 0 + + + + iPGUG7OkvUmTgbC86cl/3g== + 0 + + + + + + 2026-06-16T14:36:16Z + 2026-06-16T15:17:09Z + 2026-06-16T15:17:56Z + 2026-06-03T13:08:10Z + False + 4 + 2026-06-16T14:36:16Z + + + Notes + This is a test +Mier 1 + + + Password + Password2! + + + Title + Test Secret Mier 1 + + + URL + www.google.com + + + UserName + Test.user + + + True + 0 + + + + iPGUG7OkvUmTgbC86cl/3g== + 0 + + + + + + 2026-06-16T14:36:16Z + 2026-06-16T15:17:17Z + 2026-06-16T15:18:00Z + 2026-06-03T13:08:10Z + False + 5 + 2026-06-16T14:36:16Z + + + Notes + This is a test +Mier 1 + + + Password + Password3! + + + Title + Test Secret Mier 1 + + + URL + www.google.com + + + UserName + Test.user + + + True + 0 + + + + + + ZvJgr8KxAUC9Pd3RcxQOsQ== + 0 + + + + + + 2026-06-16T14:38:18Z + 2026-06-16T15:18:29Z + 2026-06-16T15:18:55Z + 2026-06-03T13:08:10Z + False + 5 + 2026-06-16T14:38:18Z + + + Notes + This is a test +Mier 2 + + + Password + Password4! + + + Title + Test Secret Mier 2 + + + URL + www.google.com + + + UserName + test.user2 + + + True + 0 + + + + ZvJgr8KxAUC9Pd3RcxQOsQ== + 0 + + + + + + 2026-06-16T14:38:18Z + 2026-06-16T14:39:02Z + 2026-06-16T14:39:02Z + 2026-06-03T13:08:10Z + False + 1 + 2026-06-16T14:38:18Z + + + Notes + This is a test +Mier 2 + + + Password + Password1! + + + Title + Test Secret Mier 2 + + + URL + www.google.com + + + UserName + test.user2 + + + True + 0 + + + + ZvJgr8KxAUC9Pd3RcxQOsQ== + 0 + + + + + + 2026-06-16T14:38:18Z + 2026-06-16T15:18:11Z + 2026-06-16T15:18:11Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:38:18Z + + + Notes + This is a test +Mier 2 + + + Password + Password2! + + + Title + Test Secret Mier 2 + + + URL + www.google.com + + + UserName + test.user2 + + + True + 0 + + + + ZvJgr8KxAUC9Pd3RcxQOsQ== + 0 + + + + + + 2026-06-16T14:38:18Z + 2026-06-16T15:18:20Z + 2026-06-16T15:18:20Z + 2026-06-03T13:08:10Z + False + 3 + 2026-06-16T14:38:18Z + + + Notes + This is a test +Mier 2 + + + Password + Password3! + + + Title + Test Secret Mier 2 + + + URL + www.google.com + + + UserName + test.user2 + + + True + 0 + + + + + + + 81XGZA2StUK0ca8hA5F9cw== + Baseband + + 48 + + 2026-06-16T14:33:04Z + 2026-06-16T14:33:09Z + 2026-06-16T15:18:56Z + 2026-06-15T23:00:00Z + False + 4 + 2026-06-16T14:33:04Z + + True + + Null + Null + GMS1T4gYGEq14TU1z8Lz8Q== + + GMS1T4gYGEq14TU1z8Lz8Q== + 0 + + + + + + 2026-06-16T14:39:37Z + 2026-06-16T15:19:05Z + 2026-06-16T15:19:05Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:39:37Z + + + Notes + This is a test +Baseband 1 + + + Password + Password2! + + + Title + Test Secret Baseband 1 + + + URL + + + + UserName + test.user + + + True + 0 + + + + GMS1T4gYGEq14TU1z8Lz8Q== + 0 + + + + + + 2026-06-16T14:39:37Z + 2026-06-16T14:40:29Z + 2026-06-16T14:40:29Z + 2026-06-03T13:08:10Z + False + 1 + 2026-06-16T14:39:37Z + + + Notes + This is a test +Baseband 1 + + + Password + Password1! + + + Title + Test Secret Baseband 1 + + + URL + + + + UserName + test.user + + + True + 0 + + + + + + + + OquroSdDdU+EmJLT+5DBqA== + Television + + 48 + + 2026-06-16T14:33:22Z + 2026-06-16T14:33:26Z + 2026-06-16T15:20:18Z + 2026-06-15T23:00:00Z + False + 23 + 2026-06-16T14:33:22Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + GePeTGn5Gke7SBWvLmYCsA== + Transposers + + 48 + + 2026-06-16T14:33:46Z + 2026-06-16T14:33:52Z + 2026-06-16T15:20:18Z + 2026-06-15T23:00:00Z + False + 14 + 2026-06-16T14:33:46Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + YmWvLaNgpUSIt5j5Ln30dg== + 0 + + + + + + 2026-06-16T14:40:51Z + 2026-06-16T15:19:29Z + 2026-06-16T15:19:29Z + 2026-06-03T13:08:10Z + False + 3 + 2026-06-16T14:40:51Z + + + Notes + This is a test +Transposer 1 + + + Password + Password3! + + + Title + Test secret transposer 1 + + + URL + www.google.com + + + UserName + test.user3 + + + True + 0 + + + + YmWvLaNgpUSIt5j5Ln30dg== + 0 + + + + + + 2026-06-16T14:40:51Z + 2026-06-16T15:03:20Z + 2026-06-16T15:03:20Z + 2026-06-03T13:08:10Z + False + 1 + 2026-06-16T14:40:51Z + + + Notes + This is a test +Transposer 1 + + + Password + Password1! + + + Title + Test secret transposer 1 + + + URL + www.google.com + + + UserName + test.user3 + + + True + 0 + + + + YmWvLaNgpUSIt5j5Ln30dg== + 0 + + + + + + 2026-06-16T14:40:51Z + 2026-06-16T15:19:20Z + 2026-06-16T15:19:20Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T14:40:51Z + + + Notes + This is a test +Transposer 1 + + + Password + Password2! + + + Title + Test secret transposer 1 + + + URL + www.google.com + + + UserName + test.user3 + + + True + 0 + + + + + + jTHFF4wiokCkzXG+SW1HZg== + R&S Relays + + 48 + + 2026-06-16T14:34:00Z + 2026-06-16T14:34:06Z + 2026-06-16T15:20:18Z + 2026-06-15T23:00:00Z + False + 6 + 2026-06-16T14:34:00Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + vamcp8e54EaE4nSb6Uj4Jg== + 0 + + + + + + 2026-06-16T15:12:26Z + 2026-06-16T15:19:53Z + 2026-06-16T15:20:30Z + 2026-06-03T13:08:10Z + False + 4 + 2026-06-16T15:12:26Z + + + Notes + This is a test +Scret relay 1 + + + Password + Password3Password3 + + + Title + Test Secret R&S Relay + + + URL + + + + UserName + test.user + + + True + 0 + + + + vamcp8e54EaE4nSb6Uj4Jg== + 0 + + + + + + 2026-06-16T15:12:26Z + 2026-06-16T15:13:49Z + 2026-06-16T15:13:49Z + 2026-06-03T13:08:10Z + False + 1 + 2026-06-16T15:12:26Z + + + Notes + This is a test +Scret relay 1 + + + Password + Password1Password1 + + + Title + Test Secret R&S Relay + + + URL + + + + UserName + test.user + + + True + 0 + + + + vamcp8e54EaE4nSb6Uj4Jg== + 0 + + + + + + 2026-06-16T15:12:26Z + 2026-06-16T15:19:42Z + 2026-06-16T15:19:42Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T15:12:26Z + + + Notes + This is a test +Scret relay 1 + + + Password + Password2Password2 + + + Title + Test Secret R&S Relay + + + URL + + + + UserName + test.user + + + True + 0 + + + + + + + + s5P2xgRaMk2ovXD+Ge2JWQ== + NEC Transmitters + + 48 + + 2026-06-16T14:34:18Z + 2026-06-16T14:34:24Z + 2026-06-16T15:20:04Z + 2026-06-15T23:00:00Z + False + 5 + 2026-06-16T14:34:18Z + + True + + Null + Null + AAAAAAAAAAAAAAAAAAAAAA== + + XamnkswxUkGrq4SUDPRg6A== + 0 + + + + + + 2026-06-16T15:14:01Z + 2026-06-16T15:15:18Z + 2026-06-16T15:15:18Z + 2026-06-03T13:08:10Z + False + 1 + 2026-06-16T15:14:01Z + + + Notes + + + + Password + Password1! + + + Title + Test secret NEC Transmitters + + + URL + + + + UserName + Test.user + + + True + 0 + + + + + + + jN/K/nSRM0qNnq7W20TtTg== + Test Equipment + + 48 + + 2026-06-16T14:34:58Z + 2026-06-16T14:35:05Z + 2026-06-16T15:20:05Z + 2026-06-15T23:00:00Z + False + 3 + 2026-06-16T14:34:58Z + + True + + Null + Null + P5SESXNgZkmTi6cyhkKReg== + + P5SESXNgZkmTi6cyhkKReg== + 0 + + + + + + 2026-06-16T15:15:58Z + 2026-06-16T15:20:13Z + 2026-06-16T15:20:13Z + 2026-06-03T13:08:10Z + False + 2 + 2026-06-16T15:15:58Z + + + Notes + This is a test +test equipment + + + Password + Password2! + + + Title + Test secret test equipment + + + URL + + + + UserName + test.user2 + + + True + 0 + + + + P5SESXNgZkmTi6cyhkKReg== + 0 + + + + + + 2026-06-16T15:15:58Z + 2026-06-16T15:16:35Z + 2026-06-16T15:16:35Z + 2026-06-03T13:08:10Z + False + 1 + 2026-06-16T15:15:58Z + + + Notes + This is a test +test equipment + + + Password + Password1! + + + Title + Test secret test equipment + + + URL + + + + UserName + test.user2 + + + True + 0 + + + + + + + + + \ No newline at end of file diff --git a/1password/migration/keepass/import-from-keepass-xml.py b/1password/migration/keepass/import-from-keepass-xml.py index 153baca..2c52fa5 100644 --- a/1password/migration/keepass/import-from-keepass-xml.py +++ b/1password/migration/keepass/import-from-keepass-xml.py @@ -3,25 +3,26 @@ KeePass XML → 1Password migration helper — SDK bulk create Reads a KeePass XML export (.xml, produced via File → Export → KeePass XML 2.x -in KeePass or compatible apps) and imports every entry into 1Password using the -onepassword-sdk bulk-create API. +in KeePass or compatible apps) and imports every entry into a single 1Password +vault using the onepassword-sdk bulk-create API. -PASSWORD HISTORY - Each entry's child entries are collected, deduplicated, and appended - to the item's Notes field as a human-readable block: - - --- Password History --- - 2024-01-15T09:23:00Z hunter2 - 2023-06-01T14:00:00Z correct-horse +The vault is created by the service account if it does not already exist. - The most-recent value in that differs from the current password is - listed first (newest → oldest). Duplicate passwords are suppressed. +FOLDER → TAG MAPPING + KeePass group paths are converted to tags on each item: + - Top-level group "Email" → tag "Email" + - Nested group "Email/Work" → tags "Email" and "Email/Work" + Items at the database root (no group) receive no tags. -FOLDER → VAULT MAPPING - Without --collapse-folders: - Each KeePass group path (e.g. "Email/Work") becomes its own vault. - With --collapse-folders: - All items land in the top-level group vault; sub-group names become tags. +PASSWORD HISTORY + KeePass password history is replayed as real 1Password item history: + 1. The item is created with the oldest historical password. + 2. The item is updated (put) once per subsequent historical password, + oldest → newest, ending with the current password. + Each put() call produces a genuine 1Password history entry, visible in the + UI via "View password history". Items with no history are bulk-created + normally. Dates are not preserved (1Password history timestamps reflect + when the import ran). ATTACHMENTS Binary attachments stored in elements are imported as 1Password @@ -34,17 +35,13 @@ REQUIREMENTS - OP_SERVICE_ACCOUNT_TOKEN env var - onepassword-sdk (auto-installed into .venv-1pw if missing) - - `op` CLI in PATH (only needed for user vault permission grants) USAGE python import-from-keepass-xml.py \\ --input keepass-export.xml \\ - --employee-vault "KeePass Import" \\ - [--private-prefix "Private - "] \\ - [--collapse-folders] \\ + --vault "KeePass Import" \\ [--dry-run] \\ - [--silent] \\ - [--user-for-private user@example.com] + [--silent] """ from __future__ import annotations @@ -56,13 +53,9 @@ import json import os import re -import subprocess import sys import xml.etree.ElementTree as ET -from collections import defaultdict from dataclasses import dataclass, field -from pathlib import Path -from shutil import which from typing import Dict, List, Optional, Set, Tuple # --------------------------------------------------------------------------- @@ -73,25 +66,12 @@ from onepassword import ( AutofillBehavior, FileCreateParams, - GroupAccess, ItemCategory, ItemCreateParams, ItemField, ItemFieldType, ItemSection, ItemsUpdateAllResponse, - READ_ITEMS, - REVEAL_ITEM_PASSWORD, - UPDATE_ITEM_HISTORY, - CREATE_ITEMS, - UPDATE_ITEMS, - ARCHIVE_ITEMS, - DELETE_ITEMS, - IMPORT_ITEMS, - EXPORT_ITEMS, - SEND_ITEMS, - PRINT_ITEMS, - MANAGE_VAULT, VaultCreateParams, VaultListParams, Website, @@ -105,7 +85,10 @@ _venv_python = os.path.join(_venv_dir, "bin", "python") if sys.executable == _venv_python: - print("ERROR: onepassword-sdk failed to import even inside the venv.", file=sys.stderr) + print( + "ERROR: onepassword-sdk failed to import even inside the venv.", + file=sys.stderr, + ) sys.exit(1) if not os.path.isfile(_venv_python): @@ -115,10 +98,14 @@ _req_file = os.path.join(_script_dir, "requirements.txt") if os.path.isfile(_req_file): print(f"Installing packages from {_req_file}...") - _sp.check_call([_venv_python, "-m", "pip", "install", "--quiet", "-r", _req_file]) + _sp.check_call( + [_venv_python, "-m", "pip", "install", "--quiet", "-r", _req_file] + ) else: print("Installing onepassword-sdk...") - _sp.check_call([_venv_python, "-m", "pip", "install", "--quiet", "onepassword-sdk"]) + _sp.check_call( + [_venv_python, "-m", "pip", "install", "--quiet", "onepassword-sdk"] + ) print("Restarting inside virtual environment...\n") os.execv(_venv_python, [_venv_python] + sys.argv) @@ -128,6 +115,7 @@ # 1Password client helpers (identical pattern to the Keeper migration script) # --------------------------------------------------------------------------- + async def _get_client() -> Client: token = os.getenv("OP_SERVICE_ACCOUNT_TOKEN") if not token: @@ -202,69 +190,16 @@ async def _ensure_vault( return created.id -# Permission bitmasks -_PERM_VIEWING = READ_ITEMS | REVEAL_ITEM_PASSWORD | UPDATE_ITEM_HISTORY -_PERM_EDITING = _PERM_VIEWING | CREATE_ITEMS | UPDATE_ITEMS | ARCHIVE_ITEMS | DELETE_ITEMS | IMPORT_ITEMS | EXPORT_ITEMS | SEND_ITEMS | PRINT_ITEMS -_PERM_MANAGING = _PERM_EDITING | MANAGE_VAULT - - -def _perms_list(manage_users: bool, manage_records: bool) -> List[str]: - perms = ["allow_viewing"] - if manage_records: - perms.append("allow_editing") - if manage_users: - perms.append("allow_managing") - return perms - - -def _run_op(cmd: List[str]) -> subprocess.CompletedProcess: - return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - -def _op_exists() -> bool: - return which("op") is not None - - -def _grant_user_permissions( - vault_name: str, - user_name: str, - *, - manage_users: bool, - manage_records: bool, - dry: bool, - silent: bool, -) -> None: - perms = _perms_list(manage_users, manage_records) - if dry: - if not silent: - print(f"DRY-RUN: would grant user {user_name!r} on {vault_name!r}: {perms}") - return - if not _op_exists(): - print( - f"WARN: 'op' CLI not found — cannot grant user {user_name!r} on {vault_name!r}. " - f"Run manually: op vault user grant --vault {vault_name!r} --user {user_name!r} " - f"--permissions {','.join(perms)}", - file=sys.stderr, - ) - return - cmd = ["op", "vault", "user", "grant", "--no-input", - "--vault", vault_name, "--user", user_name, - "--permissions", ",".join(perms)] - proc = _run_op(cmd) - if proc.returncode != 0: - print(f"WARN granting user {user_name!r} on {vault_name!r}: {proc.stderr.strip()}", file=sys.stderr) - elif not silent: - print(f"✔ Granted user {user_name!r} on vault {vault_name!r}: {perms}") - - # --------------------------------------------------------------------------- # Data models # --------------------------------------------------------------------------- + @dataclass class PasswordHistoryEntry: """A single historical password with its last-modification timestamp.""" - timestamp: str # ISO-8601 string from KeePass + + timestamp: str # ISO-8601 string from KeePass password: str @@ -282,7 +217,7 @@ class Record: login_url: Optional[str] notes: Optional[str] otpauth: Optional[str] - group_path: List[str] # e.g. ["Email", "Work"] + group_path: List[str] # e.g. ["Email", "Work"] attachments: List[InMemoryAttachment] = field(default_factory=list) password_history: List[PasswordHistoryEntry] = field(default_factory=list) @@ -291,6 +226,7 @@ class Record: # Resumable state file # --------------------------------------------------------------------------- + def _item_fingerprint(vault_id: str, rec: Record) -> str: parts = [ vault_id, @@ -323,7 +259,10 @@ def load_state(input_path: str, *, silent: bool) -> Set[str]: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: - print(f"WARN: Could not read state file {path}: {e}. Starting fresh.", file=sys.stderr) + print( + f"WARN: Could not read state file {path}: {e}. Starting fresh.", + file=sys.stderr, + ) return set() fingerprints = data.get("completed", []) if _compute_checksum(fingerprints) != data.get("checksum", ""): @@ -337,7 +276,11 @@ def load_state(input_path: str, *, silent: bool) -> Set[str]: def save_state(input_path: str, completed: Set[str], *, silent: bool) -> None: path = _state_file_path(input_path) fingerprints = sorted(completed) - data = {"checksum": _compute_checksum(fingerprints), "completed": fingerprints, "count": len(fingerprints)} + data = { + "checksum": _compute_checksum(fingerprints), + "completed": fingerprints, + "count": len(fingerprints), + } tmp_path = path + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) @@ -363,6 +306,7 @@ def _is_rate_limit_error(exc: Exception) -> bool: # KeePass XML parser # --------------------------------------------------------------------------- + def _text(el: Optional[ET.Element], tag: str, default: str = "") -> str: """Return stripped text of a direct child element, or default.""" if el is None: @@ -387,6 +331,7 @@ def _find_string_value(entry_el: ET.Element, key: str) -> Optional[str]: _OTPAUTH_RE = re.compile(r"otpauth://[^\s]+") + def _extract_otpauth(entry_el: ET.Element) -> Optional[str]: """Look for an otpauth:// URI in any String value or the Notes field.""" for s in entry_el.findall("String"): @@ -398,7 +343,9 @@ def _extract_otpauth(entry_el: ET.Element) -> Optional[str]: return None -def _parse_history(entry_el: ET.Element, current_password: Optional[str]) -> List[PasswordHistoryEntry]: +def _parse_history( + entry_el: ET.Element, current_password: Optional[str] +) -> List[PasswordHistoryEntry]: """ Extract blocks. @@ -445,7 +392,9 @@ def _format_history_block(history: List[PasswordHistoryEntry]) -> str: return "\n".join(lines) -def _parse_attachments(entry_el: ET.Element, binary_pool: Dict[str, bytes]) -> List[InMemoryAttachment]: +def _parse_attachments( + entry_el: ET.Element, binary_pool: Dict[str, bytes] +) -> List[InMemoryAttachment]: """ KeePass XML stores attachment content in a global pool, referenced by Ref id from . @@ -463,7 +412,10 @@ def _parse_attachments(entry_el: ET.Element, binary_pool: Dict[str, bytes]) -> L if ref is not None: content = binary_pool.get(ref) if content is None: - print(f"WARN: attachment ref {ref!r} ('{name}') not found in binary pool", file=sys.stderr) + print( + f"WARN: attachment ref {ref!r} ('{name}') not found in binary pool", + file=sys.stderr, + ) continue else: # Inline base64 @@ -473,7 +425,9 @@ def _parse_attachments(entry_el: ET.Element, binary_pool: Dict[str, bytes]) -> L try: content = base64.b64decode(raw) except Exception as e: - print(f"WARN: could not decode attachment '{name}': {e}", file=sys.stderr) + print( + f"WARN: could not decode attachment '{name}': {e}", file=sys.stderr + ) continue attachments.append(InMemoryAttachment(name=name, content=content)) return attachments @@ -504,10 +458,14 @@ def _build_binary_pool(root: ET.Element) -> Dict[str, bytes]: data = base64.b64decode(raw) if compressed: import zlib + data = zlib.decompress(data, wbits=-15) # raw deflate pool[ref_id] = data except Exception as e: - print(f"WARN: could not decode binary pool entry id={ref_id}: {e}", file=sys.stderr) + print( + f"WARN: could not decode binary pool entry id={ref_id}: {e}", + file=sys.stderr, + ) return pool @@ -536,12 +494,12 @@ def _walk_group( if entry_el.find("History") is None and entry_el.getparent() is not None: pass # ElementTree doesn't expose getparent; this is always fine - title = _find_string_value(entry_el, "Title") or "Untitled" - login = _find_string_value(entry_el, "UserName") or None + title = _find_string_value(entry_el, "Title") or "Untitled" + login = _find_string_value(entry_el, "UserName") or None password = _find_string_value(entry_el, "Password") or None - url = _find_string_value(entry_el, "URL") or None - notes = _find_string_value(entry_el, "Notes") or None - otpauth = _extract_otpauth(entry_el) + url = _find_string_value(entry_el, "URL") or None + notes = _find_string_value(entry_el, "Notes") or None + otpauth = _extract_otpauth(entry_el) # Suppress the otpauth string from appearing raw in notes if notes and otpauth and otpauth in notes: @@ -550,21 +508,29 @@ def _walk_group( history = _parse_history(entry_el, password) attachments = _parse_attachments(entry_el, binary_pool) - records.append(Record( - title=title, - login=login, - password=password, - login_url=url, - notes=notes, - otpauth=otpauth, - group_path=current_path, - attachments=attachments, - password_history=history, - )) + records.append( + Record( + title=title, + login=login, + password=password, + login_url=url, + notes=notes, + otpauth=otpauth, + group_path=current_path, + attachments=attachments, + password_history=history, + ) + ) # Recurse into sub-groups for sub_group in group_el.findall("Group"): - _walk_group(sub_group, current_path, binary_pool, records, skip_recycle_bin=skip_recycle_bin) + _walk_group( + sub_group, + current_path, + binary_pool, + records, + skip_recycle_bin=skip_recycle_bin, + ) def load_keepass_xml(input_path: str) -> List[Record]: @@ -577,8 +543,11 @@ def load_keepass_xml(input_path: str) -> List[Record]: root = tree.getroot() if root.tag != "KeePassFile": - print(f"ERROR: Expected root tag , got <{root.tag}>. " - "Is this a KeePass XML 2.x export?", file=sys.stderr) + print( + f"ERROR: Expected root tag , got <{root.tag}>. " + "Is this a KeePass XML 2.x export?", + file=sys.stderr, + ) sys.exit(2) binary_pool = _build_binary_pool(root) @@ -596,22 +565,29 @@ def load_keepass_xml(input_path: str) -> List[Record]: # The outermost group is the database name — skip it from path labelling. for entry_el in root_group.findall("Entry"): - title = _find_string_value(entry_el, "Title") or "Untitled" - login = _find_string_value(entry_el, "UserName") or None + title = _find_string_value(entry_el, "Title") or "Untitled" + login = _find_string_value(entry_el, "UserName") or None password = _find_string_value(entry_el, "Password") or None - url = _find_string_value(entry_el, "URL") or None - notes = _find_string_value(entry_el, "Notes") or None - otpauth = _extract_otpauth(entry_el) + url = _find_string_value(entry_el, "URL") or None + notes = _find_string_value(entry_el, "Notes") or None + otpauth = _extract_otpauth(entry_el) if notes and otpauth and otpauth in notes: notes = notes.replace(otpauth, "").strip() or None history = _parse_history(entry_el, password) attachments = _parse_attachments(entry_el, binary_pool) - records.append(Record( - title=title, login=login, password=password, - login_url=url, notes=notes, otpauth=otpauth, - group_path=[], # top-level items have no group path - attachments=attachments, password_history=history, - )) + records.append( + Record( + title=title, + login=login, + password=password, + login_url=url, + notes=notes, + otpauth=otpauth, + group_path=[], # top-level items have no group path + attachments=attachments, + password_history=history, + ) + ) for sub_group in root_group.findall("Group"): _walk_group(sub_group, [], binary_pool, records) @@ -620,49 +596,34 @@ def load_keepass_xml(input_path: str) -> List[Record]: # --------------------------------------------------------------------------- -# Notes field builder — merges original notes + history block +# Notes field builder # --------------------------------------------------------------------------- + def _build_notes(rec: Record) -> Optional[str]: - parts: List[str] = [] - if rec.notes: - parts.append(rec.notes) - if rec.password_history: - parts.append(_format_history_block(rec.password_history)) - return "\n\n".join(parts) if parts else None + """Return the item's original notes, unmodified. History is written via put().""" + return rec.notes or None # --------------------------------------------------------------------------- -# Vault name derivation from group path +# Tag derivation from group path # --------------------------------------------------------------------------- -def _path_to_vault_and_tags( - group_path: List[str], - *, - prefix: str, - collapse_folders: bool, -) -> Tuple[str, List[str]]: - """ - Map a group_path list to (vault_name, tags). - Without --collapse-folders: vault = prefix + full/path; no tags. - With --collapse-folders: vault = prefix + top-level group; - tag = "Parent\\Child\\Deeper" for sub-paths. +def _group_path_to_tags(group_path: List[str]) -> List[str]: """ - if not group_path: - return "", [] # caller uses employee_vault as fallback - - if not collapse_folders: - vault_name = prefix + "/".join(group_path) - return vault_name, [] + Convert a KeePass group path to a list of 1Password tags. - # collapse: parent vault only - vault_name = prefix + group_path[0] - if len(group_path) == 1: - tags = [group_path[0]] - else: - tags = ["\\".join(group_path)] - return vault_name, tags + Each level of the hierarchy gets its own tag so items are findable + by either the top-level group or the full path: + ["Email"] → ["Email"] + ["Email", "Work"] → ["Email", "Email/Work"] + Items at the database root (empty path) receive no tags. + """ + tags: List[str] = [] + for i in range(len(group_path)): + tags.append("/".join(group_path[: i + 1])) + return tags # --------------------------------------------------------------------------- @@ -671,8 +632,9 @@ def _path_to_vault_and_tags( BULK_CREATE_MAX = 100 + def _chunked(items: List, size: int) -> List[List]: - return [items[i: i + size] for i in range(0, len(items), size)] + return [items[i : i + size] for i in range(0, len(items), size)] def _make_file_params( @@ -698,23 +660,56 @@ def _build_login_params( rec: Record, notes: Optional[str], tags: Optional[List[str]], + *, + password_override: Optional[str] = None, ) -> ItemCreateParams: fields: List[ItemField] = [] + password_value = ( + password_override if password_override is not None else rec.password + ) if rec.login is not None: - fields.append(ItemField(id="username", value=rec.login, title="Username", fieldType=ItemFieldType.TEXT)) - if rec.password is not None: - fields.append(ItemField(id="password", value=rec.password, title="Password", fieldType=ItemFieldType.CONCEALED)) + fields.append( + ItemField( + id="username", + value=rec.login, + title="Username", + fieldType=ItemFieldType.TEXT, + ) + ) + if password_value is not None: + fields.append( + ItemField( + id="password", + value=password_value, + title="Password", + fieldType=ItemFieldType.CONCEALED, + ) + ) sections: List[ItemSection] = [] if rec.otpauth: sections.append(ItemSection(id="sec-otp", title="Two-Factor")) - fields.append(ItemField(id="otp", title="OTP", fieldType=ItemFieldType.TOTP, value=rec.otpauth, section_id="sec-otp")) + fields.append( + ItemField( + id="otp", + title="OTP", + fieldType=ItemFieldType.TOTP, + value=rec.otpauth, + section_id="sec-otp", + ) + ) files = _make_file_params(rec.attachments, sections) websites: List[Website] = [] if rec.login_url: - websites.append(Website(url=rec.login_url, label="site", autofill_behavior=AutofillBehavior.ANYWHEREONWEBSITE)) + websites.append( + Website( + url=rec.login_url, + label="site", + autofill_behavior=AutofillBehavior.ANYWHEREONWEBSITE, + ) + ) return ItemCreateParams( title=rec.title, @@ -741,14 +736,46 @@ def _build_secure_note_params( if rec.login or rec.password or rec.login_url: sections.append(ItemSection(id="details", title="Details")) if rec.login: - fields.append(ItemField(id="username", value=rec.login, title="Username", fieldType=ItemFieldType.TEXT, section_id="details")) + fields.append( + ItemField( + id="username", + value=rec.login, + title="Username", + fieldType=ItemFieldType.TEXT, + section_id="details", + ) + ) if rec.password: - fields.append(ItemField(id="password", value=rec.password, title="Password", fieldType=ItemFieldType.CONCEALED, section_id="details")) + fields.append( + ItemField( + id="password", + value=rec.password, + title="Password", + fieldType=ItemFieldType.CONCEALED, + section_id="details", + ) + ) if rec.login_url: - fields.append(ItemField(id="url", value=rec.login_url, title="URL", fieldType=ItemFieldType.TEXT, section_id="details")) + fields.append( + ItemField( + id="url", + value=rec.login_url, + title="URL", + fieldType=ItemFieldType.TEXT, + section_id="details", + ) + ) if rec.otpauth: sections.append(ItemSection(id="sec-otp", title="Two-Factor")) - fields.append(ItemField(id="otp", title="OTP", fieldType=ItemFieldType.TOTP, value=rec.otpauth, section_id="sec-otp")) + fields.append( + ItemField( + id="otp", + title="OTP", + fieldType=ItemFieldType.TOTP, + value=rec.otpauth, + section_id="sec-otp", + ) + ) files = _make_file_params(rec.attachments, sections) @@ -777,6 +804,7 @@ def _categorize(rec: Record) -> str: # Planner + bulk create # --------------------------------------------------------------------------- + @dataclass class _PendingItem: params: ItemCreateParams @@ -784,61 +812,86 @@ class _PendingItem: rec: Record +def _set_password_on_item(item, password: str) -> None: + """Mutate a live 1Password item object's password field value in place.""" + for f in item.fields or []: + if getattr(f, "id", None) == "password": + f.value = password + return + # Field not found — nothing to mutate (item has no password field) + + +async def _replay_history(client, item, rec: Record, *, silent: bool) -> bool: + """ + Replay KeePass password history as real 1Password item history. + + Each put() causes 1Password to save the *previous* live value into history. + To produce history [A, B, C] with current password D (D not in history): + + create(A) → put(B) → put(C) → put(D) + saves A saves B saves C + to hist to hist to hist + + The item is already created with the oldest password (A) by the caller, + so we just put() each subsequent value in order, ending with the current. + + Returns True on success, False if rate-limited. + """ + if not rec.password_history: + return True + + # history is newest-first; reverse to oldest-first, skip the first entry + # (oldest) because the item was already created with that value. + oldest_first = list(reversed(rec.password_history)) + + for h in oldest_first[1:]: + _set_password_on_item(item, h.password) + try: + item = await client.items.put(item) + except Exception as e: + if _is_rate_limit_error(e): + return False + print( + f"WARN: failed to write history entry for '{rec.title}': {e}", + file=sys.stderr, + ) + return True # non-fatal: skip remaining history for this item + + # Final put() sets the current password as the live value. + # This saves the last historical password (C) into history, not D. + _set_password_on_item(item, rec.password or "") + try: + await client.items.put(item) + except Exception as e: + if _is_rate_limit_error(e): + return False + print( + f"WARN: failed to restore current password for '{rec.title}': {e}", + file=sys.stderr, + ) + return True + + async def plan_and_apply( records: List[Record], *, input_path: str, - employee_vault: str, - private_prefix: str, - collapse_folders: bool, + vault_name: str, dry: bool, silent: bool, - user_for_private: Optional[str], ) -> None: client = await _get_client() completed = load_state(input_path, silent=silent) if not dry else set() - # Collect all vault names we'll need - vault_set: Set[str] = {employee_vault} - for rec in records: - vn, _ = _path_to_vault_and_tags(rec.group_path, prefix=private_prefix, collapse_folders=collapse_folders) - if vn: - vault_set.add(vn) - - if not silent: - print(f"Using {len(vault_set)} vault(s): {', '.join(sorted(vault_set))}") - - # Ensure all vaults exist - name_to_id, vault_titles = await _vault_name_to_id_map(client) - need_create = [v for v in vault_set if v not in name_to_id and _normalize_vault_name(v) not in name_to_id] - if need_create and not silent: - print(f"Vault(s) to create: {', '.join(sorted(need_create))}") - for v in sorted(need_create): - await _ensure_vault(client, v, name_to_id, dry=dry, silent=silent) - if not dry and need_create: - name_to_id, vault_titles = await _vault_name_to_id_map(client) - - resolved: Dict[str, str] = {} - for v in vault_set: - if dry and v in need_create: - resolved[v] = "" - else: - resolved[v] = _resolve_vault_id(name_to_id, v) - - # Optional user grants on private vaults - if user_for_private: - for vn in vault_set: - _grant_user_permissions( - vn, user_for_private, - manage_users=(vn == employee_vault), - manage_records=True, - dry=dry, silent=silent, - ) + # Ensure the single destination vault exists (create if needed) + name_to_id, _ = await _vault_name_to_id_map(client) + vault_id = await _ensure_vault( + client, vault_name, name_to_id, dry=dry, silent=silent + ) if dry: for rec in records: - vn, tags = _path_to_vault_and_tags(rec.group_path, prefix=private_prefix, collapse_folders=collapse_folders) - vault_name = vn or employee_vault + tags = _group_path_to_tags(rec.group_path) category = _categorize(rec) hist_count = len(rec.password_history) att_names = [a.name for a in rec.attachments] @@ -848,40 +901,52 @@ async def plan_and_apply( if rec.otpauth: msg += " +TOTP" if hist_count: - msg += f" +{hist_count} history entries" + msg += f" +{hist_count} history put() calls" if att_names: msg += f" +files {att_names}" print(msg) return - # Build batches per vault_id - batches: Dict[str, List[_PendingItem]] = defaultdict(list) + # Split records into two groups: + # - no_history: eligible for bulk create_all (fast path) + # - with_history: must be created individually then put() N times + no_history: List[_PendingItem] = [] + with_history: List[_PendingItem] = [] skipped = 0 for rec in records: - vn, tags = _path_to_vault_and_tags(rec.group_path, prefix=private_prefix, collapse_folders=collapse_folders) - vault_name = vn or employee_vault - vault_id = resolved[vault_name] fp = _item_fingerprint(vault_id, rec) - if fp in completed: skipped += 1 continue + tags = _group_path_to_tags(rec.group_path) or None notes = _build_notes(rec) category = _categorize(rec) - if category == "Login": - params = _build_login_params(vault_id, rec, notes, tags or None) + if rec.password_history: + # Create with the oldest historical password (A). Each subsequent + # put() then shifts the window: put(B) saves A, put(C) saves B, + # put(D) saves C — so history ends up as [A, B, C] and D is live. + oldest_password = rec.password_history[-1].password # list is newest-first + if category == "Login": + params = _build_login_params( + vault_id, rec, notes, tags, password_override=oldest_password + ) + else: + params = _build_secure_note_params(vault_id, rec, notes, tags) + with_history.append(_PendingItem(params=params, fingerprint=fp, rec=rec)) else: - params = _build_secure_note_params(vault_id, rec, notes, tags or None) - - batches[vault_id].append(_PendingItem(params=params, fingerprint=fp, rec=rec)) + if category == "Login": + params = _build_login_params(vault_id, rec, notes, tags) + else: + params = _build_secure_note_params(vault_id, rec, notes, tags) + no_history.append(_PendingItem(params=params, fingerprint=fp, rec=rec)) if skipped and not silent: print(f"⏭ Skipping {skipped} already-completed items") - total_remaining = sum(len(v) for v in batches.values()) + total_remaining = len(no_history) + len(with_history) if total_remaining == 0: if not silent: print("✔ All items already imported — nothing to do") @@ -889,54 +954,94 @@ async def plan_and_apply( return if not silent: - print(f"📦 {total_remaining} items to create") + print( + f"📦 {total_remaining} items to create in vault '{vault_name}'" + + (f" ({len(with_history)} with history replay)" if with_history else "") + ) rate_limited = False - id_to_name = {vid: name for name, vid in resolved.items()} + total_ok = 0 - for vault_id, pending_list in batches.items(): - if not pending_list or rate_limited: + # ── Fast path: bulk create items with no history ────────────────────────── + for chunk in _chunked(no_history, BULK_CREATE_MAX): + if rate_limited: + break + try: + resp: ItemsUpdateAllResponse = await client.items.create_all( + vault_id, [item.params for item in chunk] + ) + except Exception as e: + if _is_rate_limit_error(e): + print( + f"\n⚠ Rate limited during bulk create. Saving progress...", + file=sys.stderr, + ) + rate_limited = True + break + print(f"ERROR bulk create in vault '{vault_name}': {e}", file=sys.stderr) continue - vault_title = id_to_name.get(vault_id, vault_id) - if not silent: - print(f"Creating {len(pending_list)} item(s) in vault '{vault_title}'...") - total_ok = 0 - for chunk in _chunked(pending_list, BULK_CREATE_MAX): - try: - resp: ItemsUpdateAllResponse = await client.items.create_all( - vault_id, [item.params for item in chunk] - ) - except Exception as e: - if _is_rate_limit_error(e): - print(f"\n⚠ Rate limited in vault '{vault_title}'. Saving progress...", file=sys.stderr) + for i, ir in enumerate(resp.individual_responses): + if ir.error is not None: + err_str = str(ir.error).lower() + if "429" in err_str or "rate limit" in err_str: + print( + f"\n⚠ Rate limited on item '{chunk[i].rec.title}'. Saving progress...", + file=sys.stderr, + ) rate_limited = True break - print(f"ERROR bulk create in vault {vault_title}: {e}", file=sys.stderr) - continue - - for i, ir in enumerate(resp.individual_responses): - if ir.error is not None: - err_str = str(ir.error).lower() - if "429" in err_str or "rate limit" in err_str: - print(f"\n⚠ Rate limited on item '{chunk[i].rec.title}'. Saving progress...", file=sys.stderr) - rate_limited = True - break - title = chunk[i].params.title if i < len(chunk) else "?" - print(f"ERROR creating '{title}': {ir.error}", file=sys.stderr) - else: - completed.add(chunk[i].fingerprint) - total_ok += 1 - - if rate_limited: + print( + f"ERROR creating '{chunk[i].params.title}': {ir.error}", + file=sys.stderr, + ) + else: + completed.add(chunk[i].fingerprint) + total_ok += 1 + + # ── Slow path: create + put() for items that have history ───────────────── + for pending in with_history: + if rate_limited: + break + try: + item = await client.items.create(pending.params) + except Exception as e: + if _is_rate_limit_error(e): + print( + f"\n⚠ Rate limited creating '{pending.rec.title}'. Saving progress...", + file=sys.stderr, + ) + rate_limited = True break + print(f"ERROR creating '{pending.rec.title}': {e}", file=sys.stderr) + continue + ok = await _replay_history(client, item, pending.rec, silent=silent) + if not ok: + print( + f"\n⚠ Rate limited replaying history for '{pending.rec.title}'. Saving progress...", + file=sys.stderr, + ) + rate_limited = True + break + + completed.add(pending.fingerprint) + total_ok += 1 if not silent: - print(f"✔ Bulk created {total_ok}/{len(pending_list)} items in vault '{vault_title}'") + hist_count = len(pending.rec.password_history) + print( + f" ✔ '{pending.rec.title}' created with {hist_count} history entries" + ) + + if not silent: + print(f"✔ Created {total_ok}/{total_remaining} items in vault '{vault_name}'") if rate_limited: save_state(input_path, completed, silent=silent) - print("\n🔄 Import paused due to rate limiting. Re-run the same command to resume.", file=sys.stderr) + print( + "\n🔄 Import paused due to rate limiting. Re-run the same command to resume.", + file=sys.stderr, + ) sys.exit(3) else: delete_state(input_path, silent=silent) @@ -948,18 +1053,25 @@ async def plan_and_apply( # Entrypoint # --------------------------------------------------------------------------- + async def main() -> None: ap = argparse.ArgumentParser( - description="KeePass XML → 1Password migration (SDK bulk create, password history in notes)" + description="KeePass XML → 1Password migration (single vault, group paths as tags)" + ) + ap.add_argument( + "--input", required=True, help="Path to KeePass XML 2.x export (.xml)" + ) + ap.add_argument( + "--vault", + required=True, + help="Destination vault name (created if it does not exist)", + ) + ap.add_argument( + "--dry-run", + action="store_true", + help="Print planned actions without creating anything", ) - ap.add_argument("--input", required=True, help="Path to KeePass XML 2.x export (.xml)") - ap.add_argument("--employee-vault", required=True, help="Fallback vault for items without a group") - ap.add_argument("--private-prefix", default="", help="Prefix for vault names derived from groups (default: none)") - ap.add_argument("--collapse-folders", action="store_true", - help="Collapse sub-groups into the parent vault; sub-groups become tags") - ap.add_argument("--dry-run", action="store_true", help="Print planned actions without creating anything") ap.add_argument("--silent", action="store_true", help="Suppress progress output") - ap.add_argument("--user-for-private", help="Grant this user (email) edit access to all vaults") args = ap.parse_args() @@ -979,23 +1091,24 @@ async def main() -> None: sys.exit(2) if not args.silent: - att_count = sum(len(r.attachments) for r in records) + att_count = sum(len(r.attachments) for r in records) hist_count = sum(len(r.password_history) for r in records) print( f"Loaded {len(records)} entries" + (f" ({att_count} attachments)" if att_count else "") - + (f" ({hist_count} historical passwords across all entries)" if hist_count else "") + + ( + f" ({hist_count} historical passwords across all entries)" + if hist_count + else "" + ) ) await plan_and_apply( records, input_path=os.path.abspath(args.input), - employee_vault=args.employee_vault, - private_prefix=args.private_prefix, - collapse_folders=args.collapse_folders, + vault_name=args.vault, dry=args.dry_run, silent=args.silent, - user_for_private=args.user_for_private, )