Skip to content
Merged
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
146 changes: 97 additions & 49 deletions cmd/transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ var transferCreateCmd = &cobra.Command{
message, _ := cmd.Flags().GetString("message")
passphrase, _ := cmd.Flags().GetString("passphrase")
yes, _ := cmd.Flags().GetBool("yes")
toEmails, _ := cmd.Flags().GetStringArray("to")
passphraseExplicit := cmd.Flags().Changed("passphrase")

// Stat all files up front — needed for the summary and to fail early.
type fileEntry struct {
Expand Down Expand Up @@ -373,6 +375,9 @@ var transferCreateCmd = &cobra.Command{
if title != "" {
fmt.Fprintf(os.Stderr, " Title: %s\n", title)
}
if len(toEmails) > 0 {
fmt.Fprintf(os.Stderr, " To: %s\n", strings.Join(toEmails, ", "))
}
fmt.Fprintf(os.Stderr, " Expires: %s\n", formatExpiry(expire))
fmt.Fprintln(os.Stderr)
fmt.Fprint(os.Stderr, "Proceed? [y/N] ")
Expand All @@ -397,40 +402,73 @@ var transferCreateCmd = &cobra.Command{

client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug)

// Prompt for transfer passphrase if not given as a flag.
if passphrase == "" {
fmt.Fprint(os.Stderr, "Transfer passphrase: ")
pb, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprint(os.Stderr, "\r\033[2K")
if err != nil {
return fmt.Errorf("reading passphrase: %w", err)
}
passphrase = string(pb)
// Fetch the user's own public key and create the share in parallel.
var titlePtr *string
if title != "" {
titlePtr = &title
}
if passphrase == "" {
return fmt.Errorf("transfer passphrase is required")

type keyResult struct {
v *api.UserKey
err error
}
type shareResult struct {
v *api.ShareCreateResponse
err error
}
keyCh := make(chan keyResult, 1)
shareCh := make(chan shareResult, 1)

// Fetch the user's own public key so recipients of transfer info can decrypt it later.
userKey, err := client.GetActiveKey(ctx)
if err != nil {
return fmt.Errorf("fetching encryption key: %w", err)
go func() {
v, err := client.GetActiveKey(ctx)
keyCh <- keyResult{v, err}
}()
go func() {
v, err := client.CreateShare(ctx, expire, titlePtr, true, toEmails)
shareCh <- shareResult{v, err}
}()

kr := <-keyCh
if kr.err != nil {
return fmt.Errorf("fetching encryption key: %w", kr.err)
}
userKey := kr.v
if userKey == nil {
return fmt.Errorf("no active encryption key — set up your key in the web interface first")
}
Comment on lines +405 to 438
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating the share concurrently with GetActiveKey means that if GetActiveKey fails (or returns nil), the goroutine calling CreateShare may still have created a transfer on the server, and the command then returns an error without cleaning it up. Consider fetching the active key first (then creating the share), or using a cancellable context and disabling/deleting the created share on early failure to avoid leaving orphan transfers behind.

Copilot uses AI. Check for mistakes.

// Create the share on the server.
var titlePtr *string
if title != "" {
titlePtr = &title
}
share, err := client.CreateShare(ctx, expire, titlePtr, true)
if err != nil {
return fmt.Errorf("creating transfer: %w", err)
sr := <-shareCh
if sr.err != nil {
return fmt.Errorf("creating transfer: %w", sr.err)
}
share := sr.v
fmt.Fprintf(os.Stderr, "Transfer %s created, uploading…\n", share.ID)

// Decide whether a transfer passphrase is needed.
// A passphrase is not needed only when all specified recipients already have a key.
allHaveKeys := len(toEmails) > 0 && len(share.PublicKeys) == len(toEmails)
needPassphrase := !allHaveKeys || passphraseExplicit

// Inform the user if some recipients have no key and a passphrase is therefore required.
if len(toEmails) > 0 && len(share.PublicKeys) < len(toEmails) {
fmt.Fprintf(os.Stderr, "Note: %d recipient(s) have no encryption key — a transfer passphrase is required.\n",
len(toEmails)-len(share.PublicKeys))
}

// Prompt for transfer passphrase if required and not provided via flag.
if needPassphrase && passphrase == "" {
fmt.Fprint(os.Stderr, "Transfer passphrase: ")
pb, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprint(os.Stderr, "\r\033[2K")
if err != nil {
return fmt.Errorf("reading passphrase: %w", err)
}
passphrase = string(pb)
}
if needPassphrase && passphrase == "" {
return fmt.Errorf("transfer passphrase is required")
}
Comment on lines +447 to +470
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateShare is executed before prompting for a required transfer passphrase. If the passphrase is required but cannot be read (non-interactive run, user aborts, etc.), the command returns an error after having created a transfer on the server but before uploading/completing it. Consider prompting/validating the passphrase earlier when it will be required, or explicitly disabling the share on this failure path once share.ID is known.

Copilot uses AI. Check for mistakes.

// Generate session keypair — used to encrypt file content and metadata.
sessionIdentity, err := crypto.GenerateKeyPair()
if err != nil {
Expand All @@ -439,30 +477,37 @@ var transferCreateCmd = &cobra.Command{
sessionPrivKey := sessionIdentity.String()
sessionPubKey := sessionIdentity.Recipient().String()

// Encrypt session private key for the user's own key (enables transfer info).
sessionPrivKeyEnc, err := crypto.EncryptStringForKeys(sessionPrivKey, []string{userKey.PublicKey})
// Encrypt session private key for the owner and all recipients who have a key.
allPubKeys := append([]string{userKey.PublicKey}, share.PublicKeys...)
sessionPrivKeyEnc, err := crypto.EncryptStringForKeys(sessionPrivKey, allPubKeys)
if err != nil {
return fmt.Errorf("encrypting session key: %w", err)
}

// Generate an ephemeral keypair for passphrase-based recipient access.
ephemeralIdentity, err := crypto.GenerateKeyPair()
if err != nil {
return fmt.Errorf("generating ephemeral key: %w", err)
}
ephemeralPrivKey := ephemeralIdentity.String()
ephemeralPubKey := ephemeralIdentity.Recipient().String()
// Generate ephemeral keypair only when a passphrase is required.
// The backend now accepts nil ephemeral fields (updated API), so we omit them
// entirely when all recipients have a key — no passphrase path needed.
var ephemeralPrivKeyEnc, ephemeralPubKey, sessionPrivKeyEncForPassphrase string
if needPassphrase {
ephemeralIdentity, err := crypto.GenerateKeyPair()
if err != nil {
return fmt.Errorf("generating ephemeral key: %w", err)
}
ephPubKey := ephemeralIdentity.Recipient().String()
ephPrivKey := ephemeralIdentity.String()

// Encrypt the ephemeral private key with the transfer passphrase (scrypt).
ephemeralPrivKeyEnc, err := crypto.EncryptWithPassphrase([]byte(ephemeralPrivKey), passphrase)
if err != nil {
return fmt.Errorf("encrypting ephemeral key: %w", err)
}
enc, err := crypto.EncryptWithPassphrase([]byte(ephPrivKey), passphrase)
if err != nil {
return fmt.Errorf("encrypting ephemeral key: %w", err)
}
sesEnc, err := crypto.EncryptStringForKeys(sessionPrivKey, []string{ephPubKey})
if err != nil {
return fmt.Errorf("encrypting session key for passphrase access: %w", err)
}

// Encrypt session private key for the ephemeral public key (passphrase access path).
sessionPrivKeyEncForPassphrase, err := crypto.EncryptStringForKeys(sessionPrivKey, []string{ephemeralPubKey})
if err != nil {
return fmt.Errorf("encrypting session key for passphrase access: %w", err)
ephemeralPrivKeyEnc = enc
ephemeralPubKey = ephPubKey
sessionPrivKeyEncForPassphrase = sesEnc
}

// Upload each file.
Expand All @@ -482,14 +527,16 @@ var transferCreateCmd = &cobra.Command{
messageEnc = &enc
}

// Complete the transfer.
// Complete the transfer — ephemeral fields included only when a passphrase is used.
req := api.CompleteTransferRequest{
SessionPrivateKeyEnc: sessionPrivKeyEnc,
SessionPublicKey: sessionPubKey,
EphemeralPrivateKeyEnc: &ephemeralPrivKeyEnc,
EphemeralPublicKey: &ephemeralPubKey,
SessionPrivateKeyEncForPassphrase: &sessionPrivKeyEncForPassphrase,
MessageEnc: messageEnc,
SessionPrivateKeyEnc: sessionPrivKeyEnc,
SessionPublicKey: sessionPubKey,
MessageEnc: messageEnc,
}
if needPassphrase {
req.EphemeralPrivateKeyEnc = &ephemeralPrivKeyEnc
req.EphemeralPublicKey = &ephemeralPubKey
req.SessionPrivateKeyEncForPassphrase = &sessionPrivKeyEncForPassphrase
}
if err := client.CompleteTransfer(ctx, share.ID, req); err != nil {
return fmt.Errorf("completing transfer: %w", err)
Expand Down Expand Up @@ -1063,7 +1110,8 @@ func init() {
transferCreateCmd.Flags().String("title", "", "Title of the transfer")
transferCreateCmd.Flags().Int("expire", 3600, "Expiration in seconds (0 = no expiration)")
transferCreateCmd.Flags().String("message", "", "Optional message to include")
transferCreateCmd.Flags().String("passphrase", "", "Transfer passphrase for recipient access (prompted if omitted)")
transferCreateCmd.Flags().String("passphrase", "", "Transfer passphrase (prompted if required and omitted)")
transferCreateCmd.Flags().StringArray("to", nil, "Recipient email address (repeatable)")
transferCreateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

transferDownloadCmd.Flags().StringP("output", "o", "", "Destination directory (default: transfer-<random>)")
Expand Down
13 changes: 9 additions & 4 deletions internal/api/transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ func (c *Client) ListFiles(ctx context.Context, shareID string, page int) (*Tran

// ShareCreateResponse is the response from POST /share.
type ShareCreateResponse struct {
ID string `json:"id"`
Slug string `json:"slug"`
ID string `json:"id"`
Slug string `json:"slug"`
PublicKeys []string `json:"public_keys"`
}

// FileModel is the response from POST /share/{id}/file.
Expand All @@ -139,9 +140,13 @@ type CompleteTransferRequest struct {
}

// CreateShare creates a new transfer on the server.
func (c *Client) CreateShare(ctx context.Context, expires int, title *string, usePassphrase bool) (*ShareCreateResponse, error) {
// emails is the list of recipient email addresses; pass nil or empty for no recipients.
func (c *Client) CreateShare(ctx context.Context, expires int, title *string, usePassphrase bool, emails []string) (*ShareCreateResponse, error) {
if emails == nil {
emails = []string{}
}
body := map[string]any{
"emails": []string{},
"emails": emails,
"expires": expires,
"title": title,
"use_passphrase": usePassphrase,
Expand Down