Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ This project adheres to [Semantic Versioning](http://semver.org/).
Releases before this file was introduced are documented in the [GitHub releases](https://github.com/passbolt/go-passbolt-cli/releases).

## [Unreleased]
### Added
- KeePass export mirrors the Passbolt folder hierarchy as nested groups; folderless resources stay in the top group (#57, #138)
- KeePass export: `--folder` exports only the given folder and its subfolders (#75)
- KeePass export carries Passbolt tags, creation/modification timestamps and expiry dates into the KDBX entries
- KeePass export writes TOTP configuration in both conventions: the otpauth `otp` field (KeePassXC, KeeOtp2) and the native `TimeOtp-*` fields (KeePass 2.x built-in generator)
- KeePass export: `--comments` includes the Passbolt comment threads in the KeePass Notes field
- Interactive password prompts mask input with '*'; the KeePass export password is asked twice for confirmation

### Fixed
- KeePass export: harden the KDF settings (600k AES-KDF rounds for v3, 64 MiB Argon2 memory for v4) so KeePass no longer warns about weak key transformation
- KeePass export: an empty password is rejected instead of silently writing an unprotected kdbx
- KeePass export: a resource without a secret is skipped instead of crashing the export
- KeePass export: a failed export no longer leaves a partial .kdbx behind (write to temp file, rename on success), and write errors on close are reported

### Changed
- KeePass export: the top-level group is named "Passbolt Export" instead of "root"

## [0.5.2] - 2026-08-28
### Fixed
Expand Down
83 changes: 83 additions & 0 deletions cli_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/passbolt/go-passbolt-cli/internal/cmd"
"github.com/passbolt/go-passbolt-cli/internal/testenv"
"github.com/rogpeppe/go-internal/testscript"
"github.com/tobischo/gokeepasslib/v3"
)

// TestMain wires the test binary so that any `passbolt`, `pb`, or `pba`
Expand Down Expand Up @@ -94,6 +95,7 @@ func TestCLI(t *testing.T) {
"jsonexists": cmdJSONExists,
"uuid": cmdUUID,
"defer": cmdDefer,
"kdbxcheck": cmdKdbxCheck,
},
Condition: func(cond string) (bool, error) {
if name, ok := strings.CutPrefix(cond, "env:"); ok {
Expand Down Expand Up @@ -197,6 +199,87 @@ func cmdDefer(ts *testscript.TestScript, neg bool, args []string) {
})
}

// kdbxcheck <file> <password> <group/.../entryTitle> [key=value]...
//
// Asserts that the KeePass file contains an entry at the given path: a
// '/'-separated chain of group names starting at the top-level group, with
// the final segment being the entry title. Same-named sibling groups (legal
// in Passbolt and KDBX) are all searched. Extra key=value args assert entry
// field contents (protected fields are unlocked first). `! kdbxcheck`
// asserts absence of the entry and takes no field args.
func cmdKdbxCheck(ts *testscript.TestScript, neg bool, args []string) {
if len(args) < 3 || (neg && len(args) > 3) {
ts.Fatalf("usage: kdbxcheck <file> <password> <group/.../entryTitle> [key=value]...")
}
file, err := os.Open(ts.MkAbs(args[0]))
if err != nil {
ts.Fatalf("kdbxcheck open: %v", err)
}
defer file.Close()

db := gokeepasslib.NewDatabase()
db.Credentials = gokeepasslib.NewPasswordCredentials(args[1])
if err := gokeepasslib.NewDecoder(file).Decode(db); err != nil {
ts.Fatalf("kdbxcheck decode: %v", err)
}
if err := db.UnlockProtectedEntries(); err != nil {
ts.Fatalf("kdbxcheck unlock: %v", err)
}

segments := strings.Split(args[2], "/")
if len(segments) < 2 {
ts.Fatalf("kdbxcheck: path %q needs at least <topGroup>/<entryTitle>", args[2])
}
var found *gokeepasslib.Entry
for i := range db.Content.Root.Groups {
if entry := kdbxFindEntry(&db.Content.Root.Groups[i], segments); entry != nil {
found = entry
break
}
}
switch {
case found != nil && neg:
ts.Fatalf("kdbxcheck: entry %q present, expected absent", args[2])
case found == nil && !neg:
ts.Fatalf("kdbxcheck: entry %q not found", args[2])
case found == nil:
return
}

for _, assertion := range args[3:] {
key, want, ok := strings.Cut(assertion, "=")
if !ok {
ts.Fatalf("kdbxcheck: invalid field assertion %q (need key=value)", assertion)
}
if got := found.GetContent(key); got != want {
ts.Fatalf("kdbxcheck: entry %q field %s = %q, want %q", args[2], key, got, want)
}
}
}

// kdbxFindEntry returns the first entry titled segments[len-1] under the
// group chain segments[1:len-1], or nil; group must match segments[0].
func kdbxFindEntry(group *gokeepasslib.Group, segments []string) *gokeepasslib.Entry {
if group.Name != segments[0] {
return nil
}
rest := segments[1:]
if len(rest) == 1 {
for i := range group.Entries {
if group.Entries[i].GetTitle() == rest[0] {
return &group.Entries[i]
}
}
return nil
}
for i := range group.Groups {
if entry := kdbxFindEntry(&group.Groups[i], rest); entry != nil {
return entry
}
}
return nil
}

// uuid <varname> — generate a fresh UUIDv4 into env var <varname>.
func cmdUUID(ts *testscript.TestScript, neg bool, args []string) {
if neg {
Expand Down
Loading