Skip to content

Commit

Permalink
Derive a secure encryption key based on the password (#8)
Browse files Browse the repository at this point in the history
* Derive a secure encryption key based on the password

* Add tests

* Fix lint
  • Loading branch information
nakabonne committed Nov 30, 2020
1 parent 9e86a26 commit 497dfb1
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 61 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ pbgopy

# Editor settings
.idea

.DS_Store
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# pbgopy
[![Release](https://img.shields.io/github/release/nakabonne/pbgopy.svg?color=orange)](https://github.com/nakabonne/pbgopy/releases/latest)
[![Release](https://img.shields.io/github/release/nakabonne/pbgopy.svg?color=orange&style=flat-square)](https://github.com/nakabonne/pbgopy/releases/latest)
[![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/mod/github.com/nakabonne/pbgopy?tab=packages)

`pbgopy` acts like [pbcopy/pbpaste](https://www.unix.com/man-page/osx/1/pbcopy/) but for multiple devices. It lets you share data across devices like you copy and paste.
Expand Down Expand Up @@ -73,13 +73,13 @@ pbgopy serve --ttl 10m
`pbgopy` comes with an ability to encrypt/decrypt with a common key, hence allows you to perform end-to-end encryption without working with external tools.

```bash
pbgopy copy -p 32-byte-or-less-string <secret.txt
pbgopy copy -p something <secret.txt
```

Then decrypt with the same password:

```bash
pbgopy paste -p 32-byte-or-less-string
pbgopy paste -p something
```

## Inspired By
Expand Down
49 changes: 21 additions & 28 deletions commands/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,17 @@ package commands

import (
"bytes"
"crypto/aes"
"crypto/cipher"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
"time"

"github.com/spf13/cobra"
)

const dummyChar = byte('-')

type copyRunner struct {
timeout time.Duration
password string
Expand Down Expand Up @@ -50,16 +47,21 @@ func (r *copyRunner) run(_ *cobra.Command, _ []string) error {
if err != nil {
return fmt.Errorf("failed to read from STDIN: %w", err)
}

client := &http.Client{
Timeout: r.timeout,
}
if r.password != "" {
data, err = encrypt(r.password, data)
salt, err := regenerateSalt(client, address)
if err != nil {
return fmt.Errorf("failed to get salt: %w", err)
}
data, err = encrypt(r.password, salt, data)
if err != nil {
return fmt.Errorf("failed to encrypt the data: %w", err)
}
}

client := &http.Client{
Timeout: r.timeout,
}
req, err := http.NewRequest(http.MethodPut, address, bytes.NewBuffer(data))
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
Expand All @@ -70,29 +72,20 @@ func (r *copyRunner) run(_ *cobra.Command, _ []string) error {
return nil
}

func encrypt(password string, data []byte) ([]byte, error) {
p := []byte(password)
length := len(p)
if length > 32 {
return nil, fmt.Errorf("the password size should be less than 32 bytes")
// regenerateSalt lets the server regenerate the salt and gives back the new one.
func regenerateSalt(client *http.Client, address string) ([]byte, error) {
if strings.HasSuffix(address, "/") {
address = address[:len(address)-1]
}
if length < 32 {
// Fill it up with dummies
n := 32 - length
for i := 0; i < n; i++ {
p = append(p, dummyChar)
}
}

block, err := aes.NewCipher(p)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s%s", address, saltPath), bytes.NewBuffer([]byte{}))
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to make request: %w", err)
}
gcm, err := cipher.NewGCM(block)
res, err := client.Do(req)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to issue request: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
encryptedData := gcm.Seal(nonce, nonce, data, nil)
return encryptedData, nil
defer res.Body.Close()

return ioutil.ReadAll(res.Body)
}
53 changes: 53 additions & 0 deletions commands/encdec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package commands

import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt"

"golang.org/x/crypto/pbkdf2"
)

const (
defaultIterationCount = 100
keyLength = 32
)

func encrypt(password string, salt, data []byte) ([]byte, error) {
key := pbkdf2.Key([]byte(password), salt, defaultIterationCount, keyLength, sha256.New)

block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
// TODO: Stop using an empty nonce for GCM
encryptedData := gcm.Seal(nonce, nonce, data, nil)
return encryptedData, nil
}

func decrypt(password string, salt, encryptedData []byte) ([]byte, error) {
key := pbkdf2.Key([]byte(password), salt, defaultIterationCount, keyLength, sha256.New)

block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(encryptedData) < nonceSize {
return nil, fmt.Errorf("invalid cipher test")
}
nonce := encryptedData[:nonceSize]
ciphertext := encryptedData[nonceSize:]

return gcm.Open(nil, nonce, ciphertext, nil)
}
47 changes: 47 additions & 0 deletions commands/encdec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package commands

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEncryptDecrypt(t *testing.T) {
tests := []struct {
name string
passForEnc string
passForDec string
wantSuccess bool
}{
{
name: "wrong password given",
passForEnc: "password",
passForDec: "wrong-password",
wantSuccess: false,
},
{
name: "right password given",
passForEnc: "password",
passForDec: "password",
wantSuccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
data = []byte("data")
salt = []byte("salt")
cipherText = []byte{}
plainText = []byte{}
err error
)
cipherText, err = encrypt(tt.passForEnc, salt, data)
require.NoError(t, err)
plainText, err = decrypt(tt.passForDec, salt, cipherText)
assert.Equal(t, tt.wantSuccess, err == nil)

assert.Equal(t, tt.wantSuccess, string(data) == string(plainText))
})
}
}
43 changes: 14 additions & 29 deletions commands/paste.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package commands

import (
"crypto/aes"
"crypto/cipher"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -57,7 +56,11 @@ func (r *pasteRunner) run(_ *cobra.Command, _ []string) error {
return fmt.Errorf("failed to read the response body: %w", err)
}
if r.password != "" {
data, err = decrypt(r.password, data)
salt, err := getSalt(client, address)
if err != nil {
return fmt.Errorf("failed to get salt: %w", err)
}
data, err = decrypt(r.password, salt, data)
if err != nil {
return fmt.Errorf("failed to decrypt the data: %w", err)
}
Expand All @@ -67,34 +70,16 @@ func (r *pasteRunner) run(_ *cobra.Command, _ []string) error {
return nil
}

func decrypt(password string, encryptedData []byte) ([]byte, error) {
p := []byte(password)
length := len(p)
if length > 32 {
return nil, fmt.Errorf("the password size should be less than 32 bytes")
// getSalt gives back the salt.
func getSalt(client *http.Client, address string) ([]byte, error) {
if strings.HasSuffix(address, "/") {
address = address[:len(address)-1]
}
if length < 32 {
// Fill it up with dummies
n := 32 - length
for i := 0; i < n; i++ {
p = append(p, dummyChar)
}
}

block, err := aes.NewCipher(p)
res, err := client.Get(fmt.Sprintf("%s%s", address, saltPath))
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to issue get request: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(encryptedData) < nonceSize {
return nil, fmt.Errorf("invalid cipher test")
}
nonce := encryptedData[:nonceSize]
ciphertext := encryptedData[nonceSize:]
defer res.Body.Close()

return gcm.Open(nil, nonce, ciphertext, nil)
return ioutil.ReadAll(res.Body)
}
35 changes: 34 additions & 1 deletion commands/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"io/ioutil"
"log"
"math/rand"
"net/http"
"time"

Expand All @@ -18,12 +19,17 @@ import (
const (
defaultPort = 9090
defaultTTL = time.Hour * 24

rootPath = "/"
saltPath = "/salt"
)

type serveRunner struct {
port int
ttl time.Duration

// random data used as an additional input for hashing data.
salt []byte
cache cache.Cache
stdout io.Writer
stderr io.Writer
Expand Down Expand Up @@ -62,7 +68,8 @@ func (r *serveRunner) run(_ *cobra.Command, _ []string) error {
Addr: fmt.Sprintf(":%d", r.port),
Handler: mux,
}
mux.HandleFunc("/", r.handle)
mux.HandleFunc(rootPath, r.handle)
mux.HandleFunc(saltPath, r.handleSalt)

defer func() {
log.Println("Start gracefully shutting down the server")
Expand Down Expand Up @@ -108,3 +115,29 @@ func (r *serveRunner) handle(w http.ResponseWriter, req *http.Request) {
http.Error(w, fmt.Sprintf("Method %s is not allowed", req.Method), http.StatusMethodNotAllowed)
}
}

func (r *serveRunner) handleSalt(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
w.Write(r.salt)
case http.MethodPut:
r.salt = randomBytes(128)
w.Write(r.salt)
default:
http.Error(w, fmt.Sprintf("Method %s is not allowed", req.Method), http.StatusMethodNotAllowed)
}
}

const charset = "abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))

// randomBytes yields a random string with the given length.
func randomBytes(length int) []byte {
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return b
}
29 changes: 29 additions & 0 deletions commands/serve_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,30 @@
package commands

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
)

func TestRandomBytes(t *testing.T) {
validator := func(bs []byte) error {
for _, b := range bs {
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') {
continue
}
return fmt.Errorf("invalid character: %#U", b)
}
return nil
}

s1 := randomBytes(10)
assert.Equal(t, 10, len(s1))
assert.NoError(t, validator(s1))

s2 := randomBytes(10)
assert.Equal(t, 10, len(s2))
assert.NoError(t, validator(s2))

assert.NotEqual(t, s1, s2)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ go 1.15
require (
github.com/spf13/cobra v1.1.1
github.com/stretchr/testify v1.3.0
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5
)
Loading

0 comments on commit 497dfb1

Please sign in to comment.