Skip to content
Open
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
94 changes: 44 additions & 50 deletions keepass/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,71 +147,65 @@
gokeepasslib.ValueData{Key: "Notes", Value: gokeepasslib.V{Content: desc}},
)

if resource.ResourceType.Slug == "password-description-totp" || resource.ResourceType.Slug == "totp" || resource.ResourceType.Slug == "v5-default-with-totp" || resource.ResourceType.Slug == "v5-totp-standalone" {
var totpData api.SecretDataTOTP

// Extract TOTP data using generic map-based approach
if rType.HasSecretField("totp") {

Check failure on line 151 in keepass/export.go

View workflow job for this annotation

GitHub Actions / test

rType.HasSecretField undefined (type api.ResourceType has no field or method HasSecretField)
rawSecretData, err := client.DecryptMessage(resource.Secrets[0].Data)
if err != nil {
return nil, fmt.Errorf("decrypting Secret Data: %w", err)
}

switch resource.ResourceType.Slug {
case "password-description-totp":
var secretData api.SecretDataTypePasswordDescriptionTOTP
err = json.Unmarshal([]byte(rawSecretData), &secretData)
if err != nil {
return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err)
var secretMap map[string]any
err = json.Unmarshal([]byte(rawSecretData), &secretMap)
if err != nil {
return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err)
}

if totpRaw, ok := secretMap["totp"].(map[string]any); ok {
totpData := api.SecretDataTOTP{}
if s, ok := totpRaw["secret_key"].(string); ok {
totpData.SecretKey = s
}
totpData = secretData.TOTP
case "totp":
var secretData api.SecretDataTypeTOTP
err = json.Unmarshal([]byte(rawSecretData), &secretData)
if err != nil {
return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err)
if s, ok := totpRaw["algorithm"].(string); ok {
totpData.Algorithm = s
}
totpData = secretData.TOTP
case "v5-default-with-totp":
var secretData api.SecretDataTypeV5DefaultWithTOTP
err = json.Unmarshal([]byte(rawSecretData), &secretData)
if err != nil {
return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err)
if d, ok := totpRaw["digits"].(float64); ok {
totpData.Digits = int(d)
}
totpData = secretData.TOTP
case "v5-totp-standalone":
var secretData api.SecretDataTypeV5TOTPStandalone
err = json.Unmarshal([]byte(rawSecretData), &secretData)
if err != nil {
return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err)
if p, ok := totpRaw["period"].(float64); ok {
totpData.Period = int(p)
}
totpData = secretData.TOTP
}

v := url.Values{}
v.Set("secret", totpData.SecretKey)
v.Set("period", strconv.FormatUint(uint64(totpData.Period), 10))
v.Set("algorithm", totpData.Algorithm)
v.Set("digits", fmt.Sprint(totpData.Digits))
// Skip TOTP entry if secret_key is missing — can't build a valid OTP URI
if totpData.SecretKey == "" {
return &entry, nil
}

issuer := uri
if uri == "" {
issuer = name
v := url.Values{}
v.Set("secret", totpData.SecretKey)
v.Set("period", strconv.FormatUint(uint64(totpData.Period), 10))
v.Set("algorithm", totpData.Algorithm)
v.Set("digits", fmt.Sprint(totpData.Digits))

}
v.Set("issuer", issuer)
issuer := uri
if uri == "" {
issuer = name
}
v.Set("issuer", issuer)

accountName := username
if username == "" {
accountName = name
}
accountName := username
if username == "" {
accountName = name
}

u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: "/" + issuer + ":" + accountName,
RawQuery: encodeQuery(v),
}
u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: "/" + issuer + ":" + accountName,
RawQuery: encodeQuery(v),
}

entry.Values = append(entry.Values, gokeepasslib.ValueData{Key: "otp", Value: gokeepasslib.V{Content: u.String(), Protected: w.NewBoolWrapper(true)}})
entry.Values = append(entry.Values, gokeepasslib.ValueData{Key: "otp", Value: gokeepasslib.V{Content: u.String(), Protected: w.NewBoolWrapper(true)}})
}
}

return &entry, nil
Expand Down
135 changes: 109 additions & 26 deletions resource/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import (
"encoding/json"
"fmt"
"strings"

"github.com/passbolt/go-passbolt-cli/util"
"github.com/passbolt/go-passbolt/api"
"github.com/passbolt/go-passbolt/helper"
"github.com/spf13/cobra"
)
Expand All @@ -25,15 +27,12 @@
ResourceCreateCmd.Flags().StringP("description", "d", "", "Resource Description")
ResourceCreateCmd.Flags().StringP("folderParentID", "f", "", "Folder in which to create the Resource")
ResourceCreateCmd.Flags().String("expiry", "", "Expiry as RFC3339 (e.g. 2025-12-31T23:59:59Z) or Go duration (e.g. 48h, 30m)")
ResourceCreateCmd.MarkFlagRequired("name")
ResourceCreateCmd.MarkFlagRequired("password")
ResourceCreateCmd.Flags().String("type", "", "Resource type slug (e.g. v5-default, password-and-description, v5-custom-fields)")
ResourceCreateCmd.Flags().StringArray("field", []string{}, "Metadata field as key=value (repeatable; JSON values like [\"a\"] are parsed automatically)")
ResourceCreateCmd.Flags().StringArray("secret-field", []string{}, "Secret field as key=value (repeatable; JSON values are parsed automatically)")
}

func ResourceCreate(cmd *cobra.Command, args []string) error {
folderParentID, err := cmd.Flags().GetString("folderParentID")
if err != nil {
return err
}
name, err := cmd.Flags().GetString("name")
if err != nil {
return err
Expand All @@ -54,17 +53,33 @@
if err != nil {
return err
}

folderParentID, err := cmd.Flags().GetString("folderParentID")
if err != nil {
return err
}
expiry, err := cmd.Flags().GetString("expiry")
if err != nil {
return err
}

resourceType, err := cmd.Flags().GetString("type")
if err != nil {
return err
}
fields, err := cmd.Flags().GetStringArray("field")
if err != nil {
return err
}
secretFields, err := cmd.Flags().GetStringArray("secret-field")
if err != nil {
return err
}
jsonOutput, err := cmd.Flags().GetBool("json")
if err != nil {
return err
}

useGeneric := resourceType != "" || len(fields) > 0 || len(secretFields) > 0

ctx, cancel := util.GetContext()
defer cancel()

Expand All @@ -75,39 +90,107 @@
defer util.SaveSessionKeysAndLogout(ctx, client)
cmd.SilenceUsage = true

id, err := helper.CreateResource(
ctx,
client,
folderParentID,
name,
username,
uri,
password,
description,
)
var id string

if useGeneric {
// Generic path: use CreateResourceGeneric with field maps
metadataFields := map[string]any{}
secretFieldsMap := map[string]any{}

// Map standard flags to field maps
if name != "" {
metadataFields["name"] = name
}
if username != "" {
metadataFields["username"] = username
}
if uri != "" {
metadataFields["uri"] = uri
}
if description != "" {
metadataFields["description"] = description
}
if password != "" {
secretFieldsMap["password"] = password
}

// Parse --field flags
for _, f := range fields {
k, v, err := parseKeyValue(f)
if err != nil {
return fmt.Errorf("invalid --field: %w", err)
}
metadataFields[k] = v
}

// Parse --secret-field flags
for _, f := range secretFields {
k, v, err := parseKeyValue(f)
if err != nil {
return fmt.Errorf("invalid --secret-field: %w", err)
}
secretFieldsMap[k] = v
}

if resourceType == "" {
if client.MetadataTypeSettings().DefaultResourceType == api.PassboltAPIVersionTypeV5 {
resourceType = "v5-default"
} else {
resourceType = "password-and-description"
}
}

id, err = helper.CreateResourceGeneric(ctx, client, resourceType, folderParentID, metadataFields, secretFieldsMap)

Check failure on line 143 in resource/create.go

View workflow job for this annotation

GitHub Actions / lint

undefined: helper.CreateResourceGeneric

Check failure on line 143 in resource/create.go

View workflow job for this annotation

GitHub Actions / test

undefined: helper.CreateResourceGeneric
} else {
// Legacy path: use standard CreateResource
if name == "" {
return fmt.Errorf("required flag \"name\" not set")
}
if password == "" {
return fmt.Errorf("required flag \"password\" not set")
}
id, err = helper.CreateResource(ctx, client, folderParentID, name, username, uri, password, description)
}

if err != nil {
return fmt.Errorf("creating Resource: %w", err)
return fmt.Errorf("creating resource: %w", err)
}

// TODO, Should be done by go-passbolt when the "new" Resource API is done
if expiry != "" {
if err := SetResourceExpiry(ctx, client, id, expiry); err != nil {
return err
}
}

if jsonOutput {
jsonID, err := json.MarshalIndent(
map[string]string{"id": id},
"",
" ",
)
jsonID, err := json.MarshalIndent(map[string]string{"id": id}, "", " ")
if err != nil {
return fmt.Errorf("marshaling Json: %w", err)
return fmt.Errorf("marshaling json: %w", err)
}
fmt.Println(string(jsonID))
} else {
fmt.Printf("ResourceID: %v\n", id)
}
return nil
}

// parseKeyValue parses a "key=value" string. If the value looks like JSON
// (starts with [ or {), it is decoded into the appropriate Go type so that
// it is serialized correctly when marshaled back to JSON.
func parseKeyValue(s string) (string, any, error) {
parts := strings.SplitN(s, "=", 2)
if len(parts) != 2 {
return "", nil, fmt.Errorf("expected key=value, got %q", s)
}
key := parts[0]
val := parts[1]

trimmed := strings.TrimSpace(val)
if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") {
var parsed any
if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
return key, parsed, nil
}
}
return key, val, nil
}
14 changes: 14 additions & 0 deletions resource/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ var CelEnvOptions = []cel.EnvOption{
cel.Variable("Description", cel.StringType),
cel.Variable("CreatedTimestamp", cel.TimestampType),
cel.Variable("ModifiedTimestamp", cel.TimestampType),
cel.Variable("Metadata", cel.MapType(cel.StringType, cel.DynType)),
cel.Variable("Secret", cel.MapType(cel.StringType, cel.DynType)),
}

// filterDecryptedResources filters already-decrypted resources by evaluating a CEL expression.
Expand All @@ -34,6 +36,16 @@ func filterDecryptedResources(resources []decryptedResource, celCmd string, ctx

filtered := []decryptedResource{}
for _, d := range resources {
// Build metadata and secret maps for CEL, defaulting to empty maps
metadata := d.metadataFields
if metadata == nil {
metadata = map[string]any{}
}
secret := d.secretFields
if secret == nil {
secret = map[string]any{}
}

val, _, err := (*program).ContextEval(ctx, map[string]any{
"ID": d.resource.ID,
"FolderParentID": d.resource.FolderParentID,
Expand All @@ -44,6 +56,8 @@ func filterDecryptedResources(resources []decryptedResource, celCmd string, ctx
"Description": d.description,
"CreatedTimestamp": d.resource.Created.Time,
"ModifiedTimestamp": d.resource.Modified.Time,
"Metadata": metadata,
"Secret": secret,
})

if err != nil {
Expand Down
Loading
Loading