Adding folder structure to export for Keepass

This commit is contained in:
Gareth 2025-06-27 14:32:21 +10:00
parent 43cc96a43d
commit 33031b83e8
No known key found for this signature in database
GPG key ID: 6504DA502B9816F2
2 changed files with 199 additions and 30 deletions

View file

@ -118,6 +118,26 @@ passbolt exec -- gh auth login
This would resolve the passbolt:// reference in GITHUB_TOKEN to its actual secret value and pass it to the gh process. This would resolve the passbolt:// reference in GITHUB_TOKEN to its actual secret value and pass it to the gh process.
# Exporting Data
## Keepass Export
You can export your Passbolt passwords to a Keepass (.kdbx) file while preserving the folder structure.
Save your passbolt configuration first.
```bash
passbolt configure --config passbolt_server_details.json --serverAddress https://example.com --userPassword 'secret_passbolt_passphrase' --userPrivateKeyFile '/path/to/private/key/ada_private.key' --tlsSkipVerify
```
Then export your data to a password encrypted keepass file.
```bash
passbolt export keepass --file exported-passwords.kdbx -p password_for_keepass_file
```
The export preserves the folder structure from Passbolt, including nested folders. Resources not in any folder will be placed in an "Unfiled Resources" group.
The export supports both password and TOTP resources. TOTP secrets are exported in a format compatible with standard authenticator apps.
Note: This feature supports Passbolt v4 up to v5.2.0 but does not support Passbolt instances with encrypted metadata enabled.
# Documentation # Documentation
Usage for all Subcommands is [here](https://github.com/passbolt/go-passbolt-cli/wiki/passbolt). Usage for all Subcommands is [here](https://github.com/passbolt/go-passbolt-cli/wiki/passbolt).
And is also available via `man passbolt` And is also available via `man passbolt`

View file

@ -13,7 +13,6 @@ import (
"github.com/passbolt/go-passbolt-cli/util" "github.com/passbolt/go-passbolt-cli/util"
"github.com/passbolt/go-passbolt/api" "github.com/passbolt/go-passbolt/api"
"github.com/passbolt/go-passbolt/helper" "github.com/passbolt/go-passbolt/helper"
"github.com/pterm/pterm"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tobischo/gokeepasslib/v3" "github.com/tobischo/gokeepasslib/v3"
w "github.com/tobischo/gokeepasslib/v3/wrappers" w "github.com/tobischo/gokeepasslib/v3/wrappers"
@ -68,43 +67,162 @@ func KeepassExport(cmd *cobra.Command, args []string) error {
} }
fmt.Println("Getting Resources...") fmt.Println("Getting Resources...")
resources, err := client.GetResources(ctx, &api.GetResourcesOptions{ // Retrieve folder information with resources
folders, err := client.GetFolders(ctx, &api.GetFoldersOptions{
ContainChildrenResources: true,
ContainChildrenFolders: true,
})
if err != nil {
return fmt.Errorf("Getting Folders: %w", err)
}
// Also get all resources with secrets to ensure we have complete data
allResources, err := client.GetResources(ctx, &api.GetResourcesOptions{
ContainSecret: true, ContainSecret: true,
ContainResourceType: true, ContainResourceType: true,
ContainTags: true,
}) })
if err != nil { if err != nil {
return fmt.Errorf("Getting Resources: %w", err) return fmt.Errorf("Getting Resources: %w", err)
} }
// Create a map of resources by ID for easy lookup
resourceMap := make(map[string]api.Resource)
for _, resource := range allResources {
resourceMap[resource.ID] = resource
}
// Create root group
rootGroup := gokeepasslib.NewGroup()
rootGroup.Name = "Passbolt"
// Create maps to track folders and their relationships
folderMap := make(map[string]*api.Folder)
// First, store all folders in a map for easy lookup
for i := range folders {
folderMap[folders[i].ID] = &folders[i]
}
// Debug output
fmt.Printf("\nFound %d folders and %d resources\n", len(folders), len(allResources))
// Function to recursively build the folder structure
var buildFolderStructure func(parentGroupPtr *gokeepasslib.Group, folderID string)
buildFolderStructure = func(parentGroupPtr *gokeepasslib.Group, folderID string) {
folder, exists := folderMap[folderID]
if !exists {
return
}
// Create a new group for this folder
group := gokeepasslib.NewGroup()
group.Name = folder.Name
fmt.Printf("\nProcessing folder: %s (ID: %s) with %d child resources\n",
folder.Name, folder.ID, len(folder.ChildrenResources))
// Add resources to this folder's group
for _, folderResource := range folder.ChildrenResources {
// Look up the full resource with secrets
resource, exists := resourceMap[folderResource.ID]
if !exists {
fmt.Printf("\nResource %s (%s) exists in folder but not in resource list\n",
folderResource.ID, folderResource.Name)
continue
}
if len(resource.Secrets) == 0 {
fmt.Printf("\nSkipping Export of Resource %v %v Because of: no secrets available\n",
resource.ID, resource.Name)
continue
}
entry, err := getKeepassEntry(client, resource, resource.Secrets[0], resource.ResourceType)
if err != nil {
fmt.Printf("\nSkipping Export of Resource %v %v Because of: %v\n",
resource.ID, resource.Name, err)
continue
}
group.Entries = append(group.Entries, *entry)
fmt.Printf("Added resource: %s to folder: %s\n", resource.Name, folder.Name)
}
// Process child folders
for _, childFolder := range folders {
if childFolder.FolderParentID == folderID {
buildFolderStructure(&group, childFolder.ID)
}
}
// Add this group to its parent
parentGroupPtr.Groups = append(parentGroupPtr.Groups, group)
}
// Identify top-level folders (those without parents or with parents outside our folder list)
for _, folder := range folders {
if folder.FolderParentID == "" || folderMap[folder.FolderParentID] == nil {
buildFolderStructure(&rootGroup, folder.ID)
}
}
// Handle resources that are not in any folder (if any)
resourcesWithoutFolder, err := client.GetResources(ctx, &api.GetResourcesOptions{
ContainSecret: true,
ContainResourceType: true,
})
if err != nil {
return fmt.Errorf("Getting Resources without folders: %w", err)
}
// Create a group for resources without folders
noFolderGroup := gokeepasslib.NewGroup()
noFolderGroup.Name = "Unfiled Resources"
hasUnfiledResources := false
for _, resource := range resourcesWithoutFolder {
// Skip resources that are already in folders
inFolder := false
for _, folder := range folders {
for _, folderResource := range folder.ChildrenResources {
if folderResource.ID == resource.ID {
inFolder = true
break
}
}
if inFolder {
break
}
}
if !inFolder {
if len(resource.Secrets) == 0 {
fmt.Printf("\nSkipping Export of Resource %v %v Because of: no secrets available\n", resource.ID, resource.Name)
continue
}
entry, err := getKeepassEntry(client, resource, resource.Secrets[0], resource.ResourceType)
if err != nil {
fmt.Printf("\nSkipping Export of Resource %v %v Because of: %v\n", resource.ID, resource.Name, err)
continue
}
noFolderGroup.Entries = append(noFolderGroup.Entries, *entry)
hasUnfiledResources = true
}
}
// Add the unfiled resources group if it has entries
if hasUnfiledResources {
rootGroup.Groups = append(rootGroup.Groups, noFolderGroup)
}
file, err := os.Create(filename) file, err := os.Create(filename)
if err != nil { if err != nil {
return fmt.Errorf("Creating File: %w", err) return fmt.Errorf("Creating File: %w", err)
} }
defer file.Close() defer file.Close()
rootGroup := gokeepasslib.NewGroup()
rootGroup.Name = "root"
pterm.EnableStyling()
pterm.DisableColor()
progressbar, err := pterm.DefaultProgressbar.WithTitle("Decryping Resources").WithTotal(len(resources)).Start()
if err != nil {
return fmt.Errorf("Progress: %w", err)
}
for _, resource := range resources {
entry, err := getKeepassEntry(client, resource, resource.Secrets[0], resource.ResourceType)
if err != nil {
fmt.Printf("\nSkipping Export of Resource %v %v Because of: %v\n", resource.ID, resource.Name, err)
progressbar.Increment()
continue
}
rootGroup.Entries = append(rootGroup.Entries, *entry)
progressbar.Increment()
}
db := gokeepasslib.NewDatabase( db := gokeepasslib.NewDatabase(
gokeepasslib.WithDatabaseKDBXVersion4(), gokeepasslib.WithDatabaseKDBXVersion4(),
) )
@ -130,6 +248,15 @@ func KeepassExport(cmd *cobra.Command, args []string) error {
} }
func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secret, rType api.ResourceType) (*gokeepasslib.Entry, error) { func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secret, rType api.ResourceType) (*gokeepasslib.Entry, error) {
if len(resource.Secrets) == 0 {
return nil, fmt.Errorf("no secrets available")
}
// Debug output for resource type
fmt.Printf("Processing resource: %s, Type Slug: %s\n",
resource.Name,
resource.ResourceType.Slug)
_, _, _, _, pass, desc, err := helper.GetResourceFromData(client, resource, resource.Secrets[0], resource.ResourceType) _, _, _, _, pass, desc, err := helper.GetResourceFromData(client, resource, resource.Secrets[0], resource.ResourceType)
if err != nil { if err != nil {
return nil, fmt.Errorf("Get Resource %v: %w", resource.ID, err) return nil, fmt.Errorf("Get Resource %v: %w", resource.ID, err)
@ -145,7 +272,12 @@ func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secre
gokeepasslib.ValueData{Key: "Notes", Value: gokeepasslib.V{Content: desc}}, gokeepasslib.ValueData{Key: "Notes", Value: gokeepasslib.V{Content: desc}},
) )
if resource.ResourceType.Slug == "password-description-totp" || resource.ResourceType.Slug == "totp" { // Check if this is a TOTP resource
hasTOTP := resource.ResourceType.Slug == "password-description-totp" ||
resource.ResourceType.Slug == "totp"
if hasTOTP {
fmt.Printf("Found TOTP resource: %s\n", resource.Name)
var totpData api.SecretDataTOTP var totpData api.SecretDataTOTP
rawSecretData, err := client.DecryptMessage(resource.Secrets[0].Data) rawSecretData, err := client.DecryptMessage(resource.Secrets[0].Data)
@ -160,6 +292,7 @@ func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secre
return nil, fmt.Errorf("Parsing Decrypted Secret Data: %w", err) return nil, fmt.Errorf("Parsing Decrypted Secret Data: %w", err)
} }
totpData = secretData.TOTP totpData = secretData.TOTP
fmt.Printf("Parsed password-description-totp data for %s\n", resource.Name)
} else { } else {
var secretData api.SecretDataTypeTOTP var secretData api.SecretDataTypeTOTP
err = json.Unmarshal([]byte(rawSecretData), &secretData) err = json.Unmarshal([]byte(rawSecretData), &secretData)
@ -167,6 +300,12 @@ func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secre
return nil, fmt.Errorf("Parsing Decrypted Secret Data: %w", err) return nil, fmt.Errorf("Parsing Decrypted Secret Data: %w", err)
} }
totpData = secretData.TOTP totpData = secretData.TOTP
fmt.Printf("Parsed totp data for %s\n", resource.Name)
}
// Verify TOTP data
if totpData.SecretKey == "" {
fmt.Printf("Warning: TOTP secret key is empty for %s\n", resource.Name)
} }
v := url.Values{} v := url.Values{}
@ -176,14 +315,13 @@ func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secre
v.Set("digits", fmt.Sprint(totpData.Digits)) v.Set("digits", fmt.Sprint(totpData.Digits))
issuer := resource.URI issuer := resource.URI
if resource.URI == "" { if issuer == "" {
issuer = resource.Name issuer = resource.Name
} }
v.Set("issuer", issuer) v.Set("issuer", issuer)
accountName := resource.Username accountName := resource.Username
if resource.Username == "" { if accountName == "" {
accountName = resource.Name accountName = resource.Name
} }
@ -194,7 +332,18 @@ func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secre
RawQuery: encodeQuery(v), RawQuery: encodeQuery(v),
} }
entry.Values = append(entry.Values, gokeepasslib.ValueData{Key: "otp", Value: gokeepasslib.V{Content: u.String(), Protected: w.NewBoolWrapper(true)}}) otpURL := u.String()
fmt.Printf("Generated OTP URL for %s: %s\n", resource.Name, otpURL)
entry.Values = append(entry.Values,
gokeepasslib.ValueData{
Key: "otp",
Value: gokeepasslib.V{
Content: otpURL,
Protected: w.NewBoolWrapper(true),
},
},
)
} }
return &entry, nil return &entry, nil