Created
January 19, 2026 19:33
-
-
Save bgrainger/0b52711e46f0646c4c51a91603efe250 to your computer and use it in GitHub Desktop.
Import Password Safe XML to Bitwarden
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Text.Json; | |
| using System.Text.Json.Serialization; | |
| using System.Xml.Linq; | |
| using NGuid; | |
| const string inputPath = @"C:\passwords.xml"; | |
| const string outputPath = @"C:\passwords.json"; | |
| // Existing folder IDs from Bitwarden | |
| // Use 'bw get folder NAME' or URL to find existing IDs | |
| var existingFolders = new Dictionary<string, string> | |
| { | |
| ["Applications"] = "xxxxxxxx-xxxx-439c-xxxx-b3d30037c8c8", | |
| ["Financial"] = "xxxxxxxx-xxxx-41ff-xxxx-b3d30038c75e", | |
| ["Websites"] = "xxxxxxxx-xxxx-46f3-xxxx-b3d6016da122", | |
| }; | |
| // Namespace for deterministic GUIDs | |
| // NOTE: this didn't actually work because Bitwarden overwrites the GUIDs | |
| var namespaceGuid = Guid.Parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"); // DNS namespace | |
| Console.WriteLine($"Reading XML from: {inputPath}"); | |
| var doc = XDocument.Load(inputPath); | |
| // Get delimiter from XML | |
| var delimiter = doc.Root?.Attribute("delimiter")?.Value ?? "»"; | |
| Console.WriteLine($"Using delimiter: {delimiter}"); | |
| // Extract all unique groups and create folders | |
| var groups = doc.Descendants("entry") | |
| .Select(e => e.Element("group")?.Value) | |
| .Where(g => !string.IsNullOrEmpty(g)) | |
| .Distinct() | |
| .ToList(); | |
| var folders = new List<BitwardenFolder>(); | |
| var folderMap = new Dictionary<string, string>(); | |
| foreach (var group in groups) | |
| { | |
| string folderId; | |
| if (existingFolders.TryGetValue(group!, out var existingId)) | |
| { | |
| folderId = existingId; | |
| } | |
| else | |
| { | |
| // Create deterministic v5 GUID from folder name | |
| folderId = GuidHelpers.CreateFromName(namespaceGuid, group!).ToString(); | |
| } | |
| folders.Add(new BitwardenFolder { Id = folderId, Name = group! }); | |
| folderMap[group!] = folderId; | |
| } | |
| // Parse entries | |
| var items = new List<BitwardenItem>(); | |
| foreach (var entry in doc.Descendants("entry")) | |
| { | |
| var title = entry.Element("title")?.Value; | |
| var username = entry.Element("username")?.Value; | |
| var password = entry.Element("password")?.Value; | |
| var url = entry.Element("url")?.Value; | |
| var notes = entry.Element("notes")?.Value; | |
| var group = entry.Element("group")?.Value; | |
| var ctimex = entry.Element("ctimex")?.Value; | |
| var email = entry.Element("email")?.Value; | |
| // Replace delimiter with newlines in notes | |
| if (!string.IsNullOrEmpty(notes)) | |
| { | |
| notes = notes.Replace(delimiter, "\n"); | |
| } | |
| // Auto-generate URL from title if it looks like a domain | |
| if (string.IsNullOrEmpty(url) && !string.IsNullOrEmpty(title)) | |
| { | |
| if (System.Text.RegularExpressions.Regex.IsMatch(title, | |
| @"^[a-zA-Z0-9][\w\-\.]*\.(com|net|org|edu|gov|io|co|uk|ca|au|de|fr|jp|cn|in|br|ru|it|es|mx|nl|se|no|dk|fi|pl|be|ch|at|nz|ie|sg|hk|za|kr|tw|th|my|id|ph|vn|pk|eg|ng|ke|gh|tz|ug|zm|zw|us|info|biz|mobi|name|pro|tel|travel|jobs|app|dev|tech|online|site|website|store|shop|blog|cloud|email|host|space|live|me|tv|cc|ws|top|xyz|club|vip|fun|link|click)$")) | |
| { | |
| url = $"https://{title}"; | |
| } | |
| } | |
| // Parse password history | |
| var passwordHistory = new List<PasswordHistory>(); | |
| var pwhistory = entry.Element("pwhistory"); | |
| if (pwhistory != null) | |
| { | |
| var historyEntries = pwhistory.Element("history_entries")?.Elements("history_entry"); | |
| if (historyEntries != null) | |
| { | |
| foreach (var histEntry in historyEntries) | |
| { | |
| var changedx = histEntry.Element("changedx")?.Value; | |
| var oldPassword = histEntry.Element("oldpassword")?.Value; | |
| if (!string.IsNullOrEmpty(oldPassword)) | |
| { | |
| passwordHistory.Add(new PasswordHistory | |
| { | |
| LastUsedDate = ParseTimestamp(changedx), | |
| Password = oldPassword | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| var item = new BitwardenItem | |
| { | |
| OrganizationId = null, | |
| FolderId = !string.IsNullOrEmpty(group) && folderMap.ContainsKey(group) | |
| ? folderMap[group] | |
| : null, | |
| Type = 1, // Login | |
| Reprompt = 0, | |
| Name = title ?? "Untitled", | |
| Notes = notes, | |
| Favorite = false, | |
| Login = new BitwardenLogin | |
| { | |
| Username = username, | |
| Password = password, | |
| Uris = !string.IsNullOrEmpty(url) | |
| ? new[] { new BitwardenUri { Match = null, Uri = url } } | |
| : null | |
| }, | |
| PasswordHistory = passwordHistory.Count > 0 ? passwordHistory : null, | |
| CollectionIds = null | |
| }; | |
| if (DateTime.TryParse(ctimex, out var creationTime)) | |
| { | |
| item.Fields ??= []; | |
| item.Fields.Add(new() { Name = "Created Date", Value = creationTime.ToString("yyyy-MM-dd h:mm tt"), Type = 0 }); | |
| } | |
| if (!string.IsNullOrEmpty(email)) | |
| { | |
| item.Fields ??= []; | |
| item.Fields.Add(new() { Name = "Email", Value = email, Type = 0 }); | |
| } | |
| items.Add(item); | |
| } | |
| var export = new BitwardenExport | |
| { | |
| Folders = folders, | |
| Items = items | |
| }; | |
| var options = new JsonSerializerOptions | |
| { | |
| WriteIndented = true, | |
| DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | |
| }; | |
| var json = JsonSerializer.Serialize(export, options); | |
| File.WriteAllText(outputPath, json); | |
| Console.WriteLine($"Successfully exported {items.Count} items and {folders.Count} folders to: {outputPath}"); | |
| static string? ParseTimestamp(string? timestamp) | |
| { | |
| if (string.IsNullOrEmpty(timestamp)) | |
| return null; | |
| if (DateTime.TryParse(timestamp, out var dt)) | |
| return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); | |
| return null; | |
| } | |
| // Models | |
| record BitwardenExport | |
| { | |
| [JsonPropertyName("folders")] | |
| public List<BitwardenFolder> Folders { get; init; } = new(); | |
| [JsonPropertyName("items")] | |
| public List<BitwardenItem> Items { get; init; } = new(); | |
| } | |
| record BitwardenFolder | |
| { | |
| [JsonPropertyName("id")] | |
| public required string Id { get; init; } | |
| [JsonPropertyName("name")] | |
| public required string Name { get; init; } | |
| } | |
| record BitwardenItem | |
| { | |
| [JsonPropertyName("organizationId")] | |
| public string? OrganizationId { get; init; } | |
| [JsonPropertyName("folderId")] | |
| public string? FolderId { get; init; } | |
| [JsonPropertyName("type")] | |
| public int Type { get; init; } | |
| [JsonPropertyName("reprompt")] | |
| public int Reprompt { get; init; } | |
| [JsonPropertyName("name")] | |
| public required string Name { get; init; } | |
| [JsonPropertyName("notes")] | |
| public string? Notes { get; init; } | |
| [JsonPropertyName("favorite")] | |
| public bool Favorite { get; init; } | |
| [JsonPropertyName("login")] | |
| public BitwardenLogin? Login { get; init; } | |
| [JsonPropertyName("passwordHistory")] | |
| public List<PasswordHistory>? PasswordHistory { get; init; } | |
| [JsonPropertyName("collectionIds")] | |
| public string[]? CollectionIds { get; init; } | |
| [JsonPropertyName("fields")] | |
| public List<BitwardenField>? Fields { get; set; } | |
| } | |
| record BitwardenField | |
| { | |
| [JsonPropertyName("name")] | |
| public required string Name { get; init; } | |
| [JsonPropertyName("value")] | |
| public required string Value { get; init; } | |
| [JsonPropertyName("type")] | |
| public int Type { get; init; } | |
| } | |
| record BitwardenLogin | |
| { | |
| [JsonPropertyName("username")] | |
| public string? Username { get; init; } | |
| [JsonPropertyName("password")] | |
| public string? Password { get; init; } | |
| [JsonPropertyName("uris")] | |
| public BitwardenUri[]? Uris { get; init; } | |
| } | |
| record BitwardenUri | |
| { | |
| [JsonPropertyName("match")] | |
| public object? Match { get; init; } | |
| [JsonPropertyName("uri")] | |
| public required string Uri { get; init; } | |
| } | |
| record PasswordHistory | |
| { | |
| [JsonPropertyName("lastUsedDate")] | |
| public string? LastUsedDate { get; init; } | |
| [JsonPropertyName("password")] | |
| public required string Password { get; init; } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment