r/Bitwarden • • 1d ago

Export Bitwarden vault(s) to KeePass

Had a previous post that focused on exporting just the organization vault and then reworked it to include the personal vault.

TL;DR: The python script below will allow you to export everything your personal and/or organizational vaults (including attachments and TOTP codes) into an encrypted KeePass database file with a similar structure as your current Bitwarden vault.

PSA/LPT: As with all code posted on the internet, I encourage you to review the code and not just blindly run it. If you don't understand the code, you don't have to run it.

Details:

The end result is an encrypted KeePass .kdbx file with each individual collection listed as a folder, items in the archives (if selected), items in the trash (if selected), and then separate groups/folders for Credit Cards, Identities, and Secure Notes. While Passkeys aren't exported, if an entry has a Passkey, there will be a custom field/value in the .kdbx file, Passkey/missing, to help identify which one.

Also, note that while Bitwarden allows the same item to be associated with multiple collections, KeePass does not, as it treats each item separately. As a result, the KeePass database will have all your collection items in the correct collection just like Bitwarden, but there will also be separate folders for Personal Vault, Credit Cards, Identities, and Secure Notes with each of those respective items in there. Example: BW = 1 credit card in 3 collections exports 4 entries in KeePass: 3 credit card clones in each of those collections, plus a 4th in the Credit Card group/folder.

Finally, when you first open the KeePass database, right-click on the Root folder and sort alphabetically so you will have the Bitwarden personal vault items first followed by any collection items, and the separate/additional folders below.

Prerequisites:

(See script below for full code to copy and save as a .py file: bw_kp_export.py)

Note: I used the portable version of Python and instructions are verbatim what I typed (or pasted from notepad++) in the PowerShell window.

BW CLI

Python3.14.8.zip (most current version as of this post)

Python Package installer (Instructions and get-pip.py script)

KeePass Original or KeePassXC / KeePassXC Portable

  • Extract and place the bw_kp_export.py script, bitwarden, python files, and the get-pip.py script, in the same folder.
  • Edit the python###._pth file and remove the # to enable import site (scripts).
  • Open your PowerShell prompt in the python folder (example used, P:\python).

Enter the following commands:

PS P:\python>.\python.exe get-pip.py
      (this populates the Lib and Scripts folders)

PS P:\python>.\python.exe -m pip install pykeepass
      (this installs the KeePass components)

PS P:\python>.\bw login <bw account email address here>
      (type/paste password and/or the 2FA code)

Expected Response:

To unlock your vault, set your session key to the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="your session id number will be here"
> $env:BW_SESSION="copy this entire line from the $env to the end and paste it on the next line"

Enter the following commands:

PS P:\python>$env:BW_SESSION = "this is the line you copied from above and pasted here including the quote marks"

PS P:\python>.\bw sync

You can exclude this last step if you aren't exporting any organization vaults:

PS P:\python>.\bw list organizations

Expected Response:

<A list of all the organizations of which you are a member will be generated.>

Remember, you can only export the items of collections that you manage or are an admin.>

Before you run the export script, edit the bw_kp_export.py script.

  • Look for the USER CONFIGURATION section:
  • Add the organization id (or don't if you just want to export your personal vault)
  • Set the true/false flags for whether or not you want to include the archives, trash, and/or your personal vault
  • Don't forget to wrap the org id here in quotes.
  • Save changes.

Enter the following command:

PS P:\python>.\python.exe .\bw_kp_export.py

Expected Response:

Enter a password for your new bitwarden_export.kdbx file

This process shouldn't take more than 5-10 minutes (depends on the number of attachments and their files sizes). All activity live scrolls in the window and you will get a full report when complete.

There should now be an attachments folder and 4 additional files in your python folder:

bw_ov_export.json
bw_pv_export.json
bitwarden_export.kdbx
bitwarden_export.log

If you run into errors or it seems to hang, break out of the script, delete the those four files and the attachments folder, and try again. Remember, to re-run the sync if any changes are made or you will get some errors related to a mismatch.

Once you have confirmed the database opens in KeePass, probably a good idea to logout of your Bitwarden session (and be mindful that the bw_xx_export.json files and attachments folder are unencrypted copies of your vault(s)).

Enter the following command:

PS P:\python>.\bw logout

Expected Response:

You have logged out.

Here's the script: bw_kp_export.py

"""
bw_kp_export.py

Python script to export a Bitwarden personal and/or organization vault into a KeePass database file
 (including attachments and TOTP codes) organized into KeePass groups similar to the Bitwarden collections.

Group structure:
    Root
     ├── a_Personal vault (BW personal items)
     ├── <Collection Name 1>
     ├── <Collection Name 2>
     ├── ...
     ├── z_Archive       (BW archived items)
     ├── z_Credit Cards  (BW credit cards)
     ├── z_Identities    (BW identities)
     ├── z_Secure Notes  (BW secure notes)
     └── z_Trash         (BW trash items)

Multi-collection items: Except for Archive and Trash items, cloned collection items 
are added to the corresponding group and to their specific group category.

Passkeys: If a passkey is detected, a custom field 'PassKey' is added.
No detailed passkey data is exported. (search for attr:passkey)

Prerequisites (run in PowerShell BEFORE this script):
    bw login
    $env:BW_SESSION "session_id"
    bw sync
    bw list organizations       # identify your organization id for the export step and configure the options

Then just run:
    python bw_kp_export.py

Outputs:
    bw_ov_export.json        - unencrypted Bitwarden organization vault
    bw_pv_export.json        - unencrypted Bitwarden personal vault
    bitwarden_export.kdbx    - encrypted KeePass database
    attachments/             - unencrypted folder with Bitwarden vault attachments
    bitwarden_export.log     - full report: survey, settings, results, detailed log

"""
import json,os,re,subprocess,sys,time
from datetime import datetime
from urllib.parse import quote
from pykeepass import create_database

# =============================================
# USER CONFIGURATION
# =============================================
ORG_ID = ""          # import organization vault with "00000000-0000-0000-0000-000000000000" or exclude with ""
INCLUDE_ARCHIVE = True               # import/ignore archive items
INCLUDE_TRASH = True                 # import/ignore trash items
CLONE_MULTI_COLLECTION_ITEMS = True  # include/exclude items that exist in multiple collections
INCLUDE_PERSONAL_VAULT = True        # include/exclude personal vault items
# =============================================

BW_OV_JSON = "bw_ov_export.json"
BW_PV_JSON = "bw_pv_export.json"
KP_FILE = "bitwarden_export.kdbx"
ATT_DIR = "attachments"
LOG_FILE = "bitwarden_export.log"
DL_RETRIES = 2
RETRY_DELAY = 2

def check_session():
    if not os.environ.get("BW_SESSION"):
        print("Error: BW_SESSION not set!\nRun: $env:BW_SESSION = bw unlock --raw")
        sys.exit(1)

def export_org_json(jf,org):
    print(f"Exporting organization vault to {jf}...")
    cmd=["bw","export","--format","json","--organizationid",org,"--output",jf]
    r=subprocess.run(cmd,capture_output=True,text=True,env=os.environ.copy())
    if r.returncode!=0:
        print(f"Error exporting org JSON: {r.stderr}")
        sys.exit(1)
    print(f"Org export complete: {os.path.abspath(jf)}")

def export_pers_json(jf):
    print(f"Exporting personal vault to {jf}...")
    cmd=["bw","export","--format","json","--output",jf]
    r=subprocess.run(cmd,capture_output=True,text=True,env=os.environ.copy())
    if r.returncode!=0:
        print(f"Error exporting personal JSON: {r.stderr}")
        sys.exit(1)
    print(f"Personal export complete: {os.path.abspath(jf)}")

def sanitize(val):
    if not val:return ""
    if not isinstance(val,str):val=str(val)
    return re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]","",val)

def apply_totp(ent,totp,inm,usr="",iid="",log=None):
    if not totp:return False
    totp=sanitize(totp).strip()
    if not totp:return False
    try:
        sec=""
        if totp.startswith(("otpauth://","steam://")):
            uri=totp
            m=re.search(r"secret=([A-Za-z2-7=]+)",totp,re.I)
            if m:sec=m.group(1)
        else:
            sec=totp.replace(" ","")
            lbl=quote(f"{inm}:{usr}")if usr else quote(inm)
            uri=f"otpauth://totp/{lbl}?secret={sec}"
        ent.otp=uri
        if sec:ent.set_custom_property("TimeOtp-Secret-Base32",sec)
        if log:log.append(f"TOTP MAPPED | Item: {inm} | ItemID: {iid}")
        return True
    except Exception as e:
        try:
            ent.set_custom_property("TOTP_Seed",totp)
            if log:log.append(f"TOTP FALLBACK | Item: {inm} | ItemID: {iid} | Reason: {e}")
        except Exception as e2:
            if log:log.append(f"FAILED TOTP | Item: {inm} | ItemID: {iid} | Error: {e2}")
        return False

def run_bw_list(org=None,arch=False,trash=False):
    cmd=["bw","list","items"]
    if org:cmd+=["--organizationid",org]
    if arch:cmd+=["--archived"]
    if trash:cmd+=["--trash"]
    r=subprocess.run(cmd,capture_output=True,text=True,env=os.environ.copy())
    if r.returncode!=0:
        print(f"Error: {r.stderr}")
        return[]
    its=json.loads(r.stdout)
    if org:
        return [i for i in its if i.get("organizationId")==org]
    else:
        return [i for i in its if not i.get("organizationId")]

def get_colls(org):
    if not org:return{}
    r=subprocess.run(["bw","list","collections","--organizationid",org],capture_output=True,text=True,env=os.environ.copy())
    if r.returncode!=0:
        print(f"Error: {r.stderr}")
        return{}
    return{c["id"]:c.get("name","Unnamed Collection")for c in json.loads(r.stdout)}

def fetch_full(iid,log):
    r=subprocess.run(["bw","get","item",iid],capture_output=True,text=True,env=os.environ.copy())
    if r.returncode!=0:
        log.append(f"FAILED FETCH TRASH | ItemID: {iid} | Error: {r.stderr.strip()or'Unknown'}")
        return None
    try:return json.loads(r.stdout)
    except:
        log.append(f"FAILED PARSE TRASH | ItemID: {iid}")
        return None

def dl_atts(iid,inm,atts,odir,log):
    if not atts:return 0
    idir=os.path.join(odir,iid)
    os.makedirs(idir,exist_ok=True)
    cnt=0
    for att in atts:
        aid=att.get("id")
        fn=att.get("fileName","unknown")
        sfn=f"{aid}__{fn}"
        op=os.path.join(idir,sfn)
        print(f"  Downloading: {inm} / {fn}")
        ok=False
        err=""
        for a in range(1,DL_RETRIES+2):
            r=subprocess.run(["bw","get","attachment",aid,"--itemid",iid,"--output",op],capture_output=True,text=True,env=os.environ.copy())
            if r.returncode==0 and os.path.exists(op):
                ok=True
                break
            err=r.stderr.strip()or"Unknown"
            if a<=DL_RETRIES:
                print(f"    Retry {a} in {RETRY_DELAY}s...")
                time.sleep(RETRY_DELAY)
        if ok:
            cnt+=1
            print(f"    Saved: {op}")
        else:
            print(f"    FAILED: {err[:200]}")
            log.append(f"FAILED DL | Item: {inm} | ItemID: {iid} | AttID: {aid} | File: {fn}\n  Error: {err}\n")
    return cnt

def get_grp(kp,gc,nm):
    if nm in gc:return gc[nm]
    snm=sanitize(nm)or"Unnamed"
    g=kp.find_groups(name=snm,group=kp.root_group,first=True)
    if not g:g=kp.add_group(kp.root_group,snm)
    gc[nm]=g
    return g

def mk_ent(kp,g,ti,usr,pwd,url,nts):
    return kp.add_entry(g,sanitize(ti)or"Unnamed",sanitize(usr),sanitize(pwd),sanitize(url),sanitize(nts))

def handle_pk(ent,lgn,inm,iid,log):
    if not lgn.get("fido2Credentials"):return False
    ent.set_custom_property("PassKey","missing")
    log.append(f"PASSKEY | Item: {inm} | ItemID: {iid}")
    return True

def proc_card(kp,g,it,iid,log):
    inm=it.get("name","Unnamed Card")
    cd=it.get("card",{})or{}
    ent=mk_ent(kp,g,inm,"","","",sanitize(it.get("notes","")))
    flds=[("Cardholder Name","cardholderName"),("Brand","brand"),("Card Number","number",True),
          ("Expiry Month","expMonth"),("Expiry Year","expYear"),("CVV","code",True)]
    for fn,k,*prot in flds:
        v=sanitize(cd.get(k,""))
        if v:ent.set_custom_property(fn,v,protect=bool(prot))
    log.append(f"CREDIT CARD | Name: {inm} | ItemID: {iid}")
    return ent

def proc_note(kp,g,it,iid,log):
    inm=it.get("name","Unnamed Note")
    ent=mk_ent(kp,g,inm,"","","",sanitize(it.get("notes","")))
    log.append(f"SECURE NOTE | Name: {inm} | ItemID: {iid}")
    return ent

def proc_id(kp,g,it,iid,log):
    inm=it.get("name","Unnamed Identity")
    idt=it.get("identity",{})or{}
    ent=mk_ent(kp,g,inm,"","","",sanitize(it.get("notes","")))
    flds={"Title":"title","First Name":"firstName","Middle Name":"middleName","Last Name":"lastName",
          "Username":"username","Company":"company","Email":"email","Phone":"phone",
          "Address 1":"address1","Address 2":"address2","Address 3":"address3","City":"city",
          "State":"state","Postal Code":"postalCode","Country":"country",
          "SSN":"ssn","Passport Number":"passportNumber","License Number":"licenseNumber"}
    sens=["SSN","Passport Number","License Number"]
    cnt=0
    for fn,k in flds.items():
        v=sanitize(idt.get(k,""))
        if v:
            try:
                ent.set_custom_property(fn,v,protect=fn in sens)
                cnt+=1
            except Exception as e:
                log.append(f"ID FIELD FAIL | Item: {inm} | Field: {fn} | Error: {e}")
    log.append(f"IDENTITY | Name: {inm} | ItemID: {iid} | Fields: {cnt}")
    if idt:log.append(f"IDENTITY RAW | Item: {inm} | Keys: {', '.join(idt.keys())}")
    return ent

def proc_lgn(kp,g,it,iid,log):
    inm=it.get("name","Unnamed")
    lgn=it.get("login",{})or{}
    uris=lgn.get("uris",[])or[]
    ent=mk_ent(kp,g,inm,lgn.get("username",""),lgn.get("password",""),
               uris[0].get("uri","")if uris else"",it.get("notes","")or"")
    if lgn.get("totp"):apply_totp(ent,lgn["totp"],inm,lgn.get("username",""),iid,log)
    if len(uris)>1:
        for i,u in enumerate(uris):ent.set_custom_property(f"URI_{i+1}",sanitize(u.get("uri","")))
    handle_pk(ent,lgn,inm,iid,log)
    return ent

def add_props(ent,it,arch_fn,del_fn,iid,prim=True,tcpy=False,is_pers=False):
    for f in it.get("fields",[])or[]:
        fn=sanitize(f.get("name","CustomField"))or"CustomField"
        fv=sanitize(f.get("value","")or"")
        ent.set_custom_property(fn,fv,protect=f.get("type",0)==1)
    if it.get("favorite"):ent.tags.append("Favorite")
    if arch_fn(iid):ent.tags.append("Archive")
    if del_fn(iid):ent.tags.append("Trash")
    if is_pers:ent.tags.append("Personal")
    if prim:ent.tags.append("Primary")
    elif tcpy:ent.tags.append("Type-Group-Copy")
    else:ent.tags.append("Collection-Clone")
    if it.get("creationDate"):ent.ctime=datetime.fromisoformat(it["creationDate"].replace("Z","+00:00"))
    if it.get("revisionDate"):ent.mtime=datetime.fromisoformat(it["revisionDate"].replace("Z","+00:00"))

def mk_ent_dup(kp,gc,gnm,it,iid,itp,log,prim=True,tcpy=False,is_pers=False,purp=""):
    g=get_grp(kp,gc,gnm)
    try:
        if itp==1:e=proc_lgn(kp,g,it,iid,log)
        elif itp==2:e=proc_note(kp,g,it,iid,log)
        elif itp==3:e=proc_card(kp,g,it,iid,log)
        elif itp==4:e=proc_id(kp,g,it,iid,log)
        else:e=proc_lgn(kp,g,it,iid,log)
        return e,True
    except Exception as ex:
        if "already exists" in str(ex).lower():
            log.append(f"DUPLICATE DISCARDED | Name: '{it.get('name','Unnamed')}' | ItemID: {iid} | Group: '{gnm}' | Purpose: {purp}")
            return None,False
        raise

def migrate(org_jf,pers_jf,kf,ad,pw,org=None,inc_arch=True,inc_trash=False,clone_mc=True,inc_pers=True):
    check_session()
    has_org=bool(org)

    if not has_org and not inc_pers:
        print("Error: No vault selected! Enable ORG_ID or INCLUDE_PERSONAL_VAULT")
        sys.exit(1)

    # Export JSON files for backup/reference
    print("="*70+"\nEXPORTING JSON BACKUPS\n"+"="*70)
    if has_org:
        export_org_json(org_jf,org)
    if inc_pers:
        export_pers_json(pers_jf)
    print()

    log=[]

    print("="*70+"\nSTEP 1: SURVEYING VAULT\n"+"="*70)

    if has_org:
        print(f"Fetching ORGANIZATION items (ID: {org})...")
        act_org=run_bw_list(org,False,False)
        arch_org=run_bw_list(org,True,False)
        trsh_org=run_bw_list(org,False,True)
    else:
        print("No ORG_ID set - skipping organization items")
        act_org,arch_org,trsh_org=[],[],[]

    if inc_pers:
        print("Fetching PERSONAL VAULT items...")
        act_pers=run_bw_list(None,False,False)
        arch_pers=run_bw_list(None,True,False)
        trsh_pers=run_bw_list(None,False,True)
    else:
        print("Personal vault disabled - skipping personal items")
        act_pers,arch_pers,trsh_pers=[],[],[]

    act=act_org+act_pers
    arch=arch_org+arch_pers
    trsh=trsh_org+trsh_pers

    act_ids={i["id"]for i in act}
    arch_only=[i for i in arch if i["id"]not in act_ids]
    arch_ids={i["id"]for i in arch_only}
    trsh_ids={i["id"]for i in trsh}

    att_map,col_map={},{}
    for i in act+arch_only+trsh:
        iid=i["id"]
        att_map[iid]=i.get("attachments")or[]
        col_map[iid]=i.get("collectionIds")or[]

    is_del=lambda x:x in trsh_ids
    is_arch=lambda x:x in arch_ids
    is_pers=lambda x:not any(i["id"]==x and i.get("organizationId")for i in act+arch_only+trsh)

    act_cnt,arch_cnt,trsh_cnt=len(act),len(arch_only),len(trsh)
    act_cnt_org=len(act_org)
    act_cnt_pers=len(act_pers)
    arch_cnt_org=len([i for i in arch_org if i["id"]not in act_ids])
    arch_cnt_pers=len([i for i in arch_pers if i["id"]not in act_ids])
    trsh_cnt_org=len(trsh_org)
    trsh_cnt_pers=len(trsh_pers)

    act_att=sum(len(i.get("attachments")or[])for i in act)
    arch_att=sum(len(i.get("attachments")or[])for i in arch_only)
    trsh_att=sum(len(i.get("attachments")or[])for i in trsh)
    tot_det=act_cnt+arch_cnt+trsh_cnt
    tot_att=act_att+arch_att+trsh_att

    srv=[f"Active : {act_cnt} (org: {act_cnt_org}, pers: {act_cnt_pers}) (att: {act_att})",
         f"Archive: {arch_cnt} (org: {arch_cnt_org}, pers: {arch_cnt_pers}) (att: {arch_att})",
         f"Trash  : {trsh_cnt} (org: {trsh_cnt_org}, pers: {trsh_cnt_pers}) (att: {trsh_att})",
         f"TOTAL  : {tot_det} (att: {tot_att})"]
    for s in srv:print(s)
    print(f"\nSETTINGS: ORG={'Yes'if has_org else'No'} | PERS={inc_pers} | ARCH={inc_arch} | TRASH={inc_trash} | CLONE={clone_mc}\n"+"="*70+"\n")

    print("Fetching collections...")
    col_nms=get_colls(org)if has_org else{}
    print(f"Found {len(col_nms)} collections\n")

    print("Building item list from CLI data...")
    raw=act+arch_only

    filt=[]
    for i in raw:
        iid=i.get("id")
        if is_arch(iid)and not inc_arch:
            log.append(f"ARCHIVE EXCLUDED | Name: {i.get('name','Unnamed')} | ItemID: {iid}")
            continue
        filt.append(i)
        if is_arch(iid):
            log.append(f"ARCHIVE INCLUDED | Name: {i.get('name','Unnamed')} | ItemID: {iid}")

    arch_ex=sum(1 for i in raw if is_arch(i.get("id"))and not inc_arch)
    arch_in=sum(1 for i in raw if is_arch(i.get("id"))and inc_arch)

    trsh_imp=[]
    trsh_fail=0
    if trsh_cnt>0:
        if inc_trash:
            print(f"Fetching {trsh_cnt} trash items...")
            for ti in trsh:
                iid=ti.get("id")
                fi=fetch_full(iid,log)
                if fi:
                    trsh_imp.append(fi)
                    att_map[iid]=ti.get("attachments")or[]
                    col_map[iid]=ti.get("collectionIds")or[]
                    log.append(f"TRASH INCLUDED | Name: {ti.get('name','Unnamed')} | ItemID: {iid}")
                else:
                    trsh_fail+=1
                    print(f"  FAILED: {ti.get('name','Unnamed')}")
                    log.append(f"TRASH FAIL | Name: {ti.get('name','Unnamed')} | ItemID: {iid}")
        else:
            for ti in trsh:log.append(f"TRASH EXCLUDED | Name: {ti.get('name','Unnamed')} | ItemID: {ti.get('id')}")

    trsh_in=len(trsh_imp)
    trsh_ex=(trsh_cnt-trsh_in-trsh_fail)if inc_trash else trsh_cnt
    comb=filt+trsh_imp

    seen=set()
    its=[]
    dup_ex=0
    for i in comb:
        iid=i.get("id")
        if iid not in seen:
            seen.add(iid)
            its.append(i)
        else:
            dup_ex+=1
            log.append(f"DUPLICATE ID EXCLUDED | Name: {i.get('name','Unnamed')} | ItemID: {iid}")

    print(f"\nFinal: {len(its)} items (arch in: {arch_in}, trash in: {trsh_in}, dup ex: {dup_ex})\n")
    exp_ids={i.get("id")for i in its}
    tot_att_imp=sum(len(att_map.get(iid,[]))for iid in exp_ids)
    print(f"Attachments to import: {tot_att_imp}\n")

    tp_bw={1:0,2:0,3:0,4:0}
    totp_cnt,pk_cnt=0,0
    pers_cnt=sum(1 for i in its if is_pers(i.get("id")))
    org_cnt=len(its)-pers_cnt

    for i in its:
        itp=i.get("type",1)
        tp_bw[itp]=tp_bw.get(itp,0)+1
        if itp==1:
            lgn=i.get("login",{})or{}
            if lgn.get("totp"):totp_cnt+=1
            if lgn.get("fido2Credentials"):pk_cnt+=1

    print(f"Org items: {org_cnt}, Personal items: {pers_cnt}")
    print(f"TOTP: {totp_cnt}, Passkeys: {pk_cnt}")
    print(f"BITWARDEN COUNTS:\n  Logins: {tp_bw.get(1,0)}, Notes: {tp_bw.get(2,0)}, Cards: {tp_bw.get(3,0)}, IDs: {tp_bw.get(4,0)}\n  TOTAL: {sum(tp_bw.values())}\n")

    kp=create_database(kf,password=pw)
    gc={}
    tot_dl=0
    pend_att,pend_cln=[],[]
    mc_cnt=0
    totp_map,pk_det=0,0
    tp_kp={1:0,2:0,3:0,4:0}
    note_dual,card_dual,id_dual=0,0,0
    cln_cnt=0
    dup_disc=0

    for idx,it in enumerate(its):
        iid=it.get("id")
        inm=it.get("name","Unnamed")
        itp=it.get("type",1)
        is_p=is_pers(iid)

        try:
            print(f"Importing {idx+1}/{len(its)}: {inm} (type: {itp}){' [PERSONAL]'if is_p else''}")
            ex_cids=[]

            if is_arch(iid):
                tgn="z_Archive"
            elif is_del(iid):
                tgn="z_Trash"
            elif is_p:
                tgn="a_Personal Vault"
            else:
                icids=col_map.get(iid,[])
                if icids:
                    tgn=col_nms.get(icids[0],"Unassigned")
                    ex_cids=icids[1:]
                    if ex_cids:
                        mc_cnt+=1
                        anms=[col_nms.get(c,c)for c in icids]
                        log.append(f"MULTI-COLL | Name: {inm} | ItemID: {iid} | Primary: '{tgn}' | All: {', '.join(anms)}")
                else:
                    tgn="Unassigned"

            dual_ok=(tgn not in["z_Archive","z_Trash","Unassigned"])
            ent,ok=mk_ent_dup(kp,gc,tgn,it,iid,itp,log,True,False,is_p,"Primary")
            if not ok:
                dup_disc+=1
                continue
            tp_kp[itp]+=1
            add_props(ent,it,is_arch,is_del,iid,True,False,is_p)
            atts=att_map.get(iid,[])
            if atts:pend_att.append((ent,iid,inm,atts))
            if itp==1:
                lgn=it.get("login",{})or{}
                if lgn.get("totp"):totp_map+=1
                if lgn.get("fido2Credentials"):pk_det+=1

            tge=None
            if dual_ok:
                tgn=None
                if itp==2:tgn="z_Secure Notes"
                elif itp==3:tgn="z_Credit Cards"
                elif itp==4:tgn="z_Identities"
                if tgn:
                    te,tok=mk_ent_dup(kp,gc,tgn,it,iid,itp,log,False,True,is_p,f"Type-group ({tgn})")
                    if tok:
                        tp_kp[itp]+=1
                        tge=te
                        add_props(te,it,is_arch,is_del,iid,False,True,is_p)
                        if atts:pend_cln.append((te,iid,inm,atts))
                        if itp==2:note_dual+=1
                        elif itp==3:card_dual+=1
                        elif itp==4:id_dual+=1
                        log.append(f"DUAL | Name: {inm} | ItemID: {iid} | Type: {itp} | Both: '{tgn}'{' [PERSONAL]'if is_p else''}")
                    else:dup_disc+=1

            if clone_mc and ex_cids and not is_p:
                for ec in ex_cids:
                    cgn=col_nms.get(ec,"Unassigned")
                    ce,cok=mk_ent_dup(kp,gc,cgn,it,iid,itp,log,False,False,is_p,f"Clone ({cgn})")
                    if cok:
                        tp_kp[itp]+=1
                        cln_cnt+=1
                        add_props(ce,it,is_arch,is_del,iid,False,False,is_p)
                        if atts:pend_cln.append((ce,iid,inm,atts))
                        log.append(f"CLONE | Name: '{inm}' | ItemID: {iid} | Clone: '{cgn}'")
                    else:dup_disc+=1
        except Exception as e:
            print(f"  ERROR: {e}")
            log.append(f"FAILED | Item: {inm} | ItemID: {iid} | Error: {e}")
            continue

    kp.save()
    tot_kp=sum(tp_kp.values())
    print(f"\nKeePass created:\n  Groups: {len(gc)}\n  Entries: {tot_kp}\n")

    bcache={}
    if pend_att:
        print("="*60+f"\nDownloading {tot_att_imp} attachments...\n"+"="*60)
        os.makedirs(ad,exist_ok=True)
        emb=0
        for ent,iid,inm,atts in pend_att:
            idir=os.path.join(ad,iid)
            dl=dl_atts(iid,inm,atts,ad,log)
            tot_dl+=dl
            if os.path.isdir(idir):
                for fnd in os.listdir(idir):
                    fp=os.path.join(idir,fnd)
                    rfn=fnd.split("__",1)[1]if"__"in fnd else fnd
                    try:
                        with open(fp,"rb")as f:fd=f.read()
                        bid=kp.add_binary(fd)
                        bcache[(iid,rfn)]=bid
                        ent.add_attachment(bid,rfn)
                        emb+=1
                        print(f"  Embedded: '{inm}': {rfn} ({len(fd):,} bytes)")
                    except Exception as e:
                        print(f"  FAIL embed {fnd}: {e}")
                        log.append(f"FAILED EMBED | Item: {inm} | ItemID: {iid} | File: {fnd} | Error: {e}")
        kp.save()
        print(f"\nEmbedded {emb}/{tot_att_imp} attachments\n")
    else:print("No attachments to download.\n")

    cln_emb=0
    if pend_cln:
        print("="*60+f"\nEmbedding attachments into {len(pend_cln)} clones...\n"+"="*60)
        for ce,iid,inm,atts in pend_cln:
            for att in atts:
                fn=att.get("fileName","unknown")
                bid=bcache.get((iid,fn))
                if bid is not None:
                    try:
                        ce.add_attachment(bid,fn)
                        cln_emb+=1
                        print(f"  Embedded clone '{inm}': {fn}")
                    except Exception as e:
                        print(f"  FAIL: {e}")
                        log.append(f"FAILED CLONE EMBED | Item: {inm} | ItemID: {iid} | File: {fn} | Error: {e}")
                else:log.append(f"CLONE ATT SKIP | Item: {inm} | ItemID: {iid} | File: {fn}")
        kp.save()
        print(f"\nEmbedded {cln_emb} clone attachments\n")

    with open(LOG_FILE,"w",encoding="utf-8")as f:
        f.write(f"Migration: {datetime.now().isoformat()}\n"+"="*70+"\n")
        f.write("VAULT SURVEY\n"+"="*70+"\n")
        for s in srv:f.write(s+"\n")
        f.write(f"Org items: {org_cnt}, Personal items: {pers_cnt}\n")
        f.write(f"TOTP: {totp_cnt}, Passkeys: {pk_cnt}\n\n"+"="*70+"\n")
        f.write("BITWARDEN COUNTS\n"+"="*70+"\n")
        f.write(f"  Logins: {tp_bw.get(1,0)}, Notes: {tp_bw.get(2,0)}, Cards: {tp_bw.get(3,0)}, IDs: {tp_bw.get(4,0)}\n")
        f.write(f"  TOTAL: {sum(tp_bw.values())}\n  TOTP: {totp_cnt}, Passkeys: {pk_cnt}\n\n"+"="*70+"\n")
        f.write("SETTINGS\n"+"="*70+f"\nORG={'Yes ('+org+')'if has_org else'No'}\nPERS={inc_pers}\nARCH={inc_arch}\nTRASH={inc_trash}\nCLONE={clone_mc}\n\n"+"="*70+"\n")
        f.write("GROUPS\n"+"="*70+"\n")
        for gn in sorted(gc.keys()):f.write(f"  - {gn}\n")
        f.write("\n"+"="*70+"\nRESULTS\n"+"="*70+"\n")
        f.write(f"Archive detected: {arch_cnt}\n  In: {arch_in}\n  Ex: {arch_ex}\n")
        f.write(f"Trash detected: {trsh_cnt}\n  In: {trsh_in}\n  Ex: {trsh_ex}\n  Fail: {trsh_fail}\n")
        f.write(f"Dup IDs excluded: {dup_ex}\n")
        f.write(f"Dups discarded: {dup_disc}\n")
        f.write(f"Total unique imported: {len(its)}\n")
        f.write(f"Org items: {org_cnt}, Personal: {pers_cnt}\n")
        f.write(f"Attachments imported: {tot_att_imp}\n")
        f.write(f"Attachments downloaded: {tot_dl}\n")
        f.write(f"TOTP mapped: {totp_map}/{totp_cnt}\n")
        f.write(f"Passkeys detected: {pk_det}\n\n"+"="*70+"\n")
        f.write("KEEPASS COUNTS\n"+"="*70+"\n")
        f.write(f"  Logins: {tp_kp.get(1,0)}, Notes: {tp_kp.get(2,0)}, Cards: {tp_kp.get(3,0)}, IDs: {tp_kp.get(4,0)}\n")
        f.write(f"  TOTAL: {tot_kp}\n\n")
        f.write(f"DIFF (KP-BW):\n  Logins: +{tp_kp.get(1,0)-tp_bw.get(1,0)}, Notes: +{tp_kp.get(2,0)-tp_bw.get(2,0)}, Cards: +{tp_kp.get(3,0)-tp_bw.get(3,0)}, IDs: +{tp_kp.get(4,0)-tp_bw.get(4,0)}\n")
        f.write(f"  TOTAL: +{tot_kp-sum(tp_bw.values())}\n\n"+"="*70+"\n")
        f.write("DUAL PLACEMENT & CLONES\n"+"="*70+"\n")
        f.write(f"Note dual: {note_dual}, Card dual: {card_dual}, ID dual: {id_dual}\n")
        f.write(f"Multi-coll items: {mc_cnt}\n")
        f.write(f"Clones created: {cln_cnt}\n")
        f.write(f"Clone att embeds: {cln_emb}\n")
        f.write(f"TOTAL EXTRA: {note_dual+card_dual+id_dual+cln_cnt}\n\n"+"="*70+"\n")
        f.write("DETAILED LOG\n"+"="*70+"\n\n")
        if log:
            for l in log:f.write(l+"\n")
        else:f.write("No issues.\n")

    print(f"Report: {os.path.abspath(LOG_FILE)}")
    print("\n"+"="*60+"\nCOMPLETE\n"+"="*60)
    print(f"KeePass: {os.path.abspath(kf)}")
    print(f"Groups: {len(gc)}")
    print(f"Items detected: {tot_det} (org: {org_cnt}, pers: {pers_cnt})")
    print(f"\nBITWARDEN: {sum(tp_bw.values())} (L:{tp_bw.get(1,0)} N:{tp_bw.get(2,0)} C:{tp_bw.get(3,0)} I:{tp_bw.get(4,0)})")
    print(f"  TOTP: {totp_cnt}, PK: {pk_cnt}")
    print(f"\nKEEPASS: {tot_kp} (L:{tp_kp.get(1,0)} N:{tp_kp.get(2,0)} C:{tp_kp.get(3,0)} I:{tp_kp.get(4,0)})")
    print(f"\nDIFF: +{tot_kp-sum(tp_bw.values())}")
    print(f"  Note dual: {note_dual}, Card dual: {card_dual}, ID dual: {id_dual}, Clones: {cln_cnt}")
    print(f"  Arch in/ex: {arch_in}/{arch_ex}, Trash in/ex: {trsh_in}/{trsh_ex}")
    print(f"  Dups discarded: {dup_disc}")
    print(f"\nAtt downloaded: {tot_dl}/{tot_att_imp}")
    print(f"TOTP mapped: {totp_map}/{totp_cnt}")
    print(f"Passkeys: {pk_det}")
    if has_org:print(f"\nOrg JSON backup: {os.path.abspath(BW_OV_JSON)}")
    if inc_pers:print(f"Personal JSON backup: {os.path.abspath(BW_PV_JSON)}")
    print("="*60)

if __name__=="__main__":
    pw=input("Enter master password: ")
    migrate(BW_OV_JSON,BW_PV_JSON,KP_FILE,ATT_DIR,pw,ORG_ID if ORG_ID else None,INCLUDE_ARCHIVE,INCLUDE_TRASH,CLONE_MULTI_COLLECTION_ITEMS,INCLUDE_PERSONAL_VAULT)
0 Upvotes

9 comments sorted by

6

u/ouchmythumbs 1d ago

Dude, get a repo

2

u/Pradignac-Hiriche 1d ago

seriously, version control alone would be worth it

4

u/Njarnjas 1d ago

Why a script, why don't you just export your bitwarden vault and import it in KeePass? KeePassXC has bitwarden as an import option

3

u/Cueball666uk 1d ago

I've done this exactly.. just to test & see if I like it.

Also it seems in the post above that passkeys are not exported. Whereas actually after doing an import into Keepass my existing Bitwarden Passkeys also work as expected on Keepass XC on my PC and DX on android.

1

u/Reddit_Poster_00 1d ago

attachments

2

u/Wrong_Specialist709 1d ago

Dayum son.. Never scrolled so much on a post

1

u/Reddit_Poster_00 1d ago edited 1d ago

So unfortunately, you can't export/import data in organization vaults the same way - so this version just added the option of including the personal vault.

Also, even if you exported the .json file and/or the full .zip from your personal vault - you still have to manually bring over your attachments. This script basically does a near mirror of how your vault looks in bitwarden and makes it available in another format (with the exception of passkeys).

So while the passkey export vault isn't ideal - you may want to create a different passkey for the KeePass database anyway - and the exported kdbx file will at least make those entry items easy to identify.

(Also, even from within Bitwarden's clone option - the passkeys aren't part of the clone so you would have to create/add one anyway.)

1

u/ouchmythumbs 1d ago

unfortunately, you can't export/import data in organization vaults the same way

Is this because of the org's policy settings, or it true across all orgs in BW regardless?

1

u/Reddit_Poster_00 1d ago

At a minimum you need to be admin of a collection in an organization (or owner/admin of organization) in order to export, and there might be some additional settings to prevent that from happening as well, but I think once you are admin - that's all you need.