r/Bitwarden • u/Reddit_Poster_00 • 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.
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)
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
2
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.
6
u/ouchmythumbs 1d ago
Dude, get a repo