Skip to content

Instantly share code, notes, and snippets.

@wodin
Last active November 27, 2023 14:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wodin/7526b5c5435189b456020827d8a351bd to your computer and use it in GitHub Desktop.
Save wodin/7526b5c5435189b456020827d8a351bd to your computer and use it in GitHub Desktop.
Migration from Buttercup to Bitwarden
#!/usr/bin/env python3
import sys
import csv
import json
from collections import OrderedDict
# !type
# !group_id
# !group_name
# !group_parent
# title
# username
# password
# id
# URL
# LoginURL
# Download URL
# Pre-release URL
def read_csv(infile):
csv_reader = csv.reader(infile)
header = next(csv_reader)
entries = []
for row in csv_reader:
entry = OrderedDict()
for k, v in zip(header, row):
if v:
if k == "url":
k = "URL"
elif k == "LOGINURL":
k = "LoginURL"
if k in entry:
raise Exception("Duplicate key!", k)
entry[k] = v
entries.append(entry)
return entries
def groups_to_folders(groups):
return sorted(groups.values(), key=lambda x: x["name"].lower())
def main(infile, outfile):
bcup_entries = read_csv(open(infile))
groups = {}
items = []
for entry in bcup_entries:
if entry["!type"] == "group":
group = {}
group["id"] = entry["!group_id"]
if entry["!group_parent"] != "0":
parent = groups[entry["!group_parent"]]
parent_name = parent["name"]
group["name"] = "%s/%s" % (parent_name, entry["!group_name"])
else:
group["name"] = entry["!group_name"]
if group["id"] in groups:
raise Exception("Duplicate group ID!", group["id"])
groups[group["id"]] = group
elif entry["!type"] == "entry":
# Just do treat everything as a "Login" for now
item = {
"organisationId": None,
"type": 1,
"favorite": False,
"collectionIds": None
}
item["id"] = entry["id"]
if entry["!group_id"] not in groups:
raise Exception("Group not found for entry!", entry["!group_id"], entry["title"])
item["folderId"] = entry["!group_id"]
item["name"] = entry["title"]
notes = []
handled = []
for k, v in entry.items():
# It seems Bitwarden has a maximum length of 1000 characters on the values
if "\n" in v or len(v) > 1000:
notes.append(k)
notes.append("-" * 40)
notes.append(v)
notes.append("-" * 40)
notes.append("\n")
handled.append(k)
if notes:
notes = "\n".join(notes)
else:
notes = None
item["notes"] = notes
# Fields. Should we just make them all hidden?
"""
"fields": [
{
"name": "hidden 1",
"value": "value 1",
"type": 1
}
],
"""
fields = []
for k, v in entry.items():
if k.startswith("!"):
continue
if k in ["title", "username", "password", "id", "URL", "LoginURL"]:
continue
if k in handled:
continue
fields.append({
"name": k,
"value": v,
"type": 1
})
if fields:
item["fields"] = fields
# As I said, "login"
if "username" not in entry:
entry["username"] = ""
if "password" not in entry:
entry["password"] = ""
login = {
"username": entry["username"],
"password": entry["password"],
"totp": None,
}
# Use default match option and just do URL and LoginURL
uris = []
if "URL" in entry:
uris.append({"match": None, "uri": entry["URL"]})
if "LoginURL" in entry:
uris.append({"match": None, "uri": entry["LoginURL"]})
if uris:
login["uris"] = uris
item["login"] = login
items.append(item)
else:
raise Exception("Unexpected !type", entry["!type"])
bitwarden = {
"encrypted": False,
"folders": groups_to_folders(groups),
"items": items
}
# print(json.dumps(bitwarden, indent=2))
json.dump(bitwarden, open(outfile, "w"), indent=2)
if __name__ == "__main__":
infile = sys.argv[1]
outfile = sys.argv[2]
main(infile, outfile)

Buttercup group

{
  "!type": "group",
  "!group_id": "14ea0416-4b14-4b28-a6e6-f44002840cdd",
  "!group_name": "General",
  "!group_parent": "0"
}

Buttercup child group

Handled in Bitwarden by giving it a name like "Parent/Child"

{
  "!type": "group",
  "!group_id": "dde4c317-8341-47fc-8e41-a1d4ba4902d9",
  "!group_name": "Keys",
  "!group_parent": "64426c39-b196-4622-9db0-f54b8ea3e03b"
}

Buttercup login entry

{
  "!type": "entry",
  "!group_id": "14ea0416-4b14-4b28-a6e6-f44002840cdd",
  "title": "Some site",
  "username": "user@example.com",
  "password": "password 1234",
  "id": "f0930417-0329-4aa9-88e9-1763713c2e09",
  "URL": "https://example.com/",
  "some key": "some value"
}

Buttercup credit card entry

Not worth bothering about

{
  "!type": "entry",
  "!group_id": "64426c39-b196-4622-9db0-f54b8ea3e03b",
  "title": "Some Credit Card",
  "username": "MR FJ BLOGGS",
  "password": "1111222233334444",
  "id": "0b4bdb3d-0236-4887-bdba-cc17aa0aa094",
  "some key": "some value",
  "type": "visa",
  "cvv": "000",
  "expiry": "mmyyyy"
}

Buttercup OTP entry

Not worth bothering about

{
  "!type": "entry",
  "!group_id": "64426c39-b196-4622-9db0-f54b8ea3e03b",
  "title": "Some site",
  "username": "fred.bloggs@example.com",
  "password": "password 2345",
  "id": "40cf2507-950d-4048-a727-70448dd44059",
  "URL": "https://www.example.com/",
  "LoginURL": "https://login.example.com/",
  "OTP name": "otpauth://totp/Some%20Name:something?secret=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&issuer=Some%20Name"
}

Item types

    1 = Login
    2 = Secure note
    3 = Card
    4 = Identity

Custom field types

    0 = text field
    1 = hidden field
    2 = boolean field

URL match types

    null = default
    0 = base domain
    # host?
    # starts with?
    4 = regex
    # exact?
    # never?

Let's just use the default URL matching

"Standard" fields

# !type             (group or entry)
# !group_id         (group's ID. entry's group_id)
# !group_name       (group name (if type is group))
# !group_parent     (ID of parent group or "0")
# title             (entry's name)
# username          (username...)
# password          (password...)
# id                (entry's ID)
# URL               (main(?) URL. Often without schema)
# LoginURL          (Login page URL. Usually with schema)

Notes

kv pairs where the value has newlines should probably be stored in notes. But what about the key names? What if there are multiple kv pairs like this in an entry? Will just add like this:

k1
----------------
v1
----------------

k2
----------------
v2
----------------

Trash

Looks like Bitwarden doesn't export Trash, so probably can't import into it either. Will just treat like a normal group for now.

Example Bitwarden export

{
  "encrypted": false,
  "folders": [
    {
      "id": "77445048-6309-441f-a767-ace300b7d077",
      "name": "Some Group"
    }
  ],
  "items": [
    {
      "id": "fdf13ded-3d26-41d7-831d-ace300c2d5f2",
      "organizationId": null,
      "folderId": "51667684-f131-42cc-aa7f-ace300b7d077",
      "type": 3,
      "name": "Fred Bloggs CC",
      "notes": "This is a note",
      "favorite": false,
      "fields": [
        {
          "name": "custom text field",
          "value": "custom text value",
          "type": 0
        },
        {
          "name": "custom hidden field",
          "value": "custom hidden value",
          "type": 1
        },
        {
          "name": "custom boolean value",
          "value": "true",
          "type": 2
        }
      ],
      "card": {
        "cardholderName": "Fred Bloggs",
        "brand": "Visa",
        "number": "1234567890123456",
        "expMonth": "1",
        "expYear": "2022",
        "code": "998"
      },
      "collectionIds": null
    },
    {
      "id": "d2bdb46b-6538-4042-ab1f-ace300cb7557",
      "organizationId": null,
      "folderId": "51667684-f131-42cc-aa7f-ace300b7d077",
      "type": 1,
      "name": "Fred Bloggs Login",
      "notes": "note line 1\nnote line 2\nnote line 3",
      "favorite": false,
      "fields": [
        {
          "name": "hidden 1",
          "value": "value 1",
          "type": 1
        }
      ],
      "login": {
        "uris": [
          {
            "match": null,
            "uri": "https://www.example.com/"
          },
          {
            "match": 0,
            "uri": "https://a.example.com/"
          },
          {
            "match": 4,
            "uri": "https://b[0-9]*\\.example\\.com/"
          }
        ],
        "username": "fred.bloggs",
        "password": "password 3456",
        "totp": null
      },
      "collectionIds": null
    }
  ]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment