Skip to content

Commit

Permalink
Add encrypted sessions
Browse files Browse the repository at this point in the history
Attempt to open an encrypted session when opening a session using the
`dh-ietf1024-sha256-aes128-cbc-pkcs7` algorithm specified in the Secret
Service specification. If we fail to open a session with that algorithm,
fall back to a `plain` session. Use the AES key negotiated when opening
the session to encrypt and decrypt the secrets exchanged with the Secret
Service.

The crypto routines in `crypto.go` have been taken from
https://github.com/mvdan/bitw - my thanks go to Daniel Martí for writing
this as it saved me a lot of headaches reading, understanding,
implementing and testing the algorithms as specified in the various
RFCs.

A new dependency = `golang.org/x/[email protected]` has been added, used by
the crypto routines needed to implemented encrypted sessions. Just the
`golang.org/x/crypto/hkdf` package of that module is used.

Turn off the `mnd` (magic number detector) linter as I prefer to decide
when magic numbers are appropriate or not, and not just cargo-cult the
idea that there should be none.
  • Loading branch information
camh- committed Sep 4, 2024
1 parent 29238cc commit ce35151
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 16 deletions.
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ linters:
- gomnd
- ireturn
- lll
- mnd
- nlreturn
- nonamedreturns
- paralleltest
Expand Down
135 changes: 135 additions & 0 deletions crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package main

// This entirety of this file has come from https://github.com/mvdan/bitw/blob/master/crypto.go
// Small changes have been made to satisfy the linter.
// Copyright (c) 2019, Daniel Martí <[email protected]>
// Under the BSD 3-Clause license
// https://github.com/mvdan/bitw/blob/d4600876932c7e27feb32b17c83c8f933388c30f/LICENSE

import (
"crypto/aes"
"crypto/cipher"
cryptorand "crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"math"
"math/big"

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

type dhGroup struct {
g, p, pMinus1 *big.Int
}

var bigOne = big.NewInt(1)

func (dg *dhGroup) NewKeypair() (private, public *big.Int, err error) {
for {
if private, err = cryptorand.Int(cryptorand.Reader, dg.pMinus1); err != nil {
return nil, nil, err
}
if private.Sign() > 0 {
break
}
}
public = new(big.Int).Exp(dg.g, private, dg.p)
return private, public, nil
}

func (dg *dhGroup) diffieHellman(theirPublic, myPrivate *big.Int) (*big.Int, error) {
if theirPublic.Cmp(bigOne) <= 0 || theirPublic.Cmp(dg.pMinus1) >= 0 {
return nil, errors.New("DH parameter out of bounds")
}
return new(big.Int).Exp(theirPublic, myPrivate, dg.p), nil
}

func rfc2409SecondOakleyGroup() *dhGroup {
p, _ := new(big.Int).SetString("FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF", 16)
return &dhGroup{
g: new(big.Int).SetInt64(2),
p: p,
pMinus1: new(big.Int).Sub(p, bigOne),
}
}

func (dg *dhGroup) keygenHKDFSHA256AES128(theirPublic *big.Int, myPrivate *big.Int) ([]byte, error) {
sharedSecret, err := dg.diffieHellman(theirPublic, myPrivate)
if err != nil {
return nil, err
}

r := hkdf.New(sha256.New, sharedSecret.Bytes(), nil, nil)
aesKey := make([]byte, 16)
if _, err := io.ReadFull(r, aesKey); err != nil {
return nil, err
}
return aesKey, nil
}

func unauthenticatedAESCBCEncrypt(data, key []byte) (iv, ciphertext []byte, _ error) {
data = padPKCS7(data, aes.BlockSize)
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, err
}
ivSize := aes.BlockSize
iv = make([]byte, ivSize)
ciphertext = make([]byte, len(data))
if _, err := io.ReadFull(cryptorand.Reader, iv); err != nil {
return nil, nil, err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, data)
return iv, ciphertext, nil
}

func unauthenticatedAESCBCDecrypt(iv, ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != aes.BlockSize {
return nil, errors.New("iv length does not match AES block size")
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, errors.New("ciphertext is not a multiple of AES block size")
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext) // decrypt in-place
data, err := unpadPKCS7(ciphertext, aes.BlockSize)
if err != nil {
return nil, err
}
return data, nil
}

func unpadPKCS7(src []byte, size int) ([]byte, error) {
n := src[len(src)-1]
if len(src)%size != 0 {
return nil, fmt.Errorf("expected PKCS7 padding for block size %d, but have %d bytes", size, len(src))
}
if len(src) <= int(n) {
return nil, fmt.Errorf("cannot unpad %d bytes out of a total of %d", n, len(src))
}
src = src[:len(src)-int(n)]
return src, nil
}

func padPKCS7(src []byte, size int) []byte {
// Note that we always pad, even if rem==0. This is because unpad must
// always remove at least one byte to be unambiguous.
rem := len(src) % size
n := size - rem
if n > math.MaxUint8 {
panic(fmt.Sprintf("cannot pad over %d bytes, but got %d", math.MaxUint8, n))
}
padded := make([]byte, len(src)+n)
copy(padded, src)
for i := len(src); i < len(padded); i++ {
padded[i] = byte(n)
}
return padded
}
155 changes: 139 additions & 16 deletions dbus.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"iter"
"math/big"
"os"
"strings"

"github.com/godbus/dbus/v5"
Expand All @@ -26,6 +28,7 @@ type SecretService struct {
conn *dbus.Conn
svc dbus.BusObject
session dbus.BusObject
aesKey []byte
}

// Secret is a struct compatible with the [Secret] struct type as defined in
Expand Down Expand Up @@ -56,22 +59,93 @@ func NewSecretService() (*SecretService, error) {
if err != nil {
return nil, fmt.Errorf("couldn't connect to session bus: %w", err)
}

svc := conn.Object("org.freedesktop.secrets", dbus.ObjectPath("/org/freedesktop/secrets"))

var path dbus.ObjectPath
ss := &SecretService{
conn: conn,
svc: svc,
}

if err := ss.OpenSession(); err != nil {
return nil, err
}

return ss, nil
}

// OpenSession opens a [session] to the secret service. Upon opening a session,
// an AES key may be generated to secure the [transfer of secrets] with the
// Secret Service. If no AES key is generated (len(ss.aesKey) == 0), then
// secrets are not encrypted across the bus.
//
// We first try to negotiate an encrypted session, and if that fails we
// fallback to a plain session. Not all implementations of the Secret Service
// may support encrypted sessions.
//
// [session]: https://specifications.freedesktop.org/secret-service-spec/latest/sessions.html
// [transfer of secrets]: https://specifications.freedesktop.org/secret-service-spec/latest/transfer-secrets.html
func (ss *SecretService) OpenSession() error {
sessionPath, err := ss.openDHSession()
if err != nil {
fmt.Fprintln(os.Stderr, "Warning: Failed to open encrypted session. Falling back to unencrypted.")
sessionPath, err = ss.openPlainSession()
if err != nil {
return err
}
}
ss.session = ss.conn.Object("org.freedesktop.secrets", sessionPath)
return nil
}

// openPlainSession opens a [plain] session to the secret service. Secets are
// not encrypted over the bus with a plain session. It returns the path to the
// session if successful, otherwise it returns an error.
//
// [plain]: https://specifications.freedesktop.org/secret-service-spec/latest/ch07s02.html
func (ss *SecretService) openPlainSession() (dbus.ObjectPath, error) {
input := dbus.MakeVariant("")
var output dbus.Variant
call := svc.Call("org.freedesktop.Secret.Service.OpenSession", 0, "plain", dbus.MakeVariant(""))
if err := call.Store(&output, &path); err != nil {
return nil, fmt.Errorf("couldn't open secret session: %w", err)
var sessionPath dbus.ObjectPath
call := ss.svc.Call("org.freedesktop.Secret.Service.OpenSession", 0, "plain", input)
err := call.Store(&output, &sessionPath)
return sessionPath, err
}

// openDHSession opens a [dh-ietf1024-sha256-aes128-cbc-pkcs7] session to the
// secret service. Secrets are encrypted using an AES key generated via a
// Diffie-Hellman exchange performed when opening the session. It returns the
// path to the session if successful, otherwise it returns an error.
//
// [dh-ietf1024-sha256-aes128-cbc-pkcs7]: https://specifications.freedesktop.org/secret-service-spec/latest/ch07s03.html
func (ss *SecretService) openDHSession() (dbus.ObjectPath, error) {
group := rfc2409SecondOakleyGroup()
private, public, err := group.NewKeypair()
if err != nil {
return "", err
}

session := conn.Object("org.freedesktop.secrets", path)
input := dbus.MakeVariant(public.Bytes()) // math/big.Int.Bytes is big endian
var output dbus.Variant
var sessionPath dbus.ObjectPath
call := ss.svc.Call("org.freedesktop.Secret.Service.OpenSession", 0, "dh-ietf1024-sha256-aes128-cbc-pkcs7", input)
err = call.Store(&output, &sessionPath)
if err != nil {
return "", err
}

outputBytes, ok := output.Value().([]byte)
if !ok {
return "", fmt.Errorf("output type of OpenSession was not []bytes: %T", output.Value())
}

return &SecretService{
conn: conn,
svc: svc,
session: session,
}, nil
theirPublic := new(big.Int)
theirPublic.SetBytes(outputBytes)
ss.aesKey, err = group.keygenHKDFSHA256AES128(theirPublic, private)
if err != nil {
return "", err
}
return sessionPath, nil
}

// Close closes the session with the secret service, making it no longer
Expand Down Expand Up @@ -123,7 +197,11 @@ func (ss *SecretService) Get(attrs map[string]string) (string, error) {
if err != nil {
return "", err
}
return string(secret.Secret), nil
sec, err := ss.unmarshalSecret(&secret)
if err != nil {
return "", err
}
return sec, nil
}

return "", nil
Expand All @@ -140,10 +218,9 @@ func (ss *SecretService) Store(label string, attrs map[string]string, secret str
"org.freedesktop.Secret.Item.Label": dbus.MakeVariant(label),
"org.freedesktop.Secret.Item.Attributes": dbus.MakeVariant(attrs),
}
sec := Secret{
Session: ss.session.Path(),
Secret: []byte(secret),
ContentType: "text/plain",
sec, err := ss.marshalSecret(secret)
if err != nil {
return err
}

// Try to unlock the collection first. Will be a no-op if it is not locked
Expand Down Expand Up @@ -199,7 +276,11 @@ func (ss *SecretService) Delete(attrs map[string]string, expectedPassword string
if err != nil {
return err
}
password, _, _ := strings.Cut(string(secret.Secret), "\n")
sec, err := ss.unmarshalSecret(&secret)
if err != nil {
return err
}
password, _, _ := strings.Cut(sec, "\n")
if password != expectedPassword {
continue
}
Expand All @@ -226,6 +307,48 @@ func (ss *SecretService) Delete(attrs map[string]string, expectedPassword string
return ss.prompt(promptPath)
}

// marshalSecret marshals the given secret into a Secret struct suitable for
// passing to the Secret Service for storage. If the receiver has an AES key,
// it is used to encrypt the secret as well as to populate the initialisation
// vector (IV) that is the parameter of the Secret. If the AES key in the
// receiver is empty, the secret is not encrypted. If there was an error
// encrypting the secret, it is returned.
func (ss *SecretService) marshalSecret(secret string) (*Secret, error) {
sec := &Secret{
Session: ss.session.Path(),
Secret: []byte(secret),
ContentType: "text/plain",
}

if len(ss.aesKey) > 0 {
iv, ciphertext, err := unauthenticatedAESCBCEncrypt([]byte(secret), ss.aesKey)
if err != nil {
return nil, err
}
sec.Params = iv
sec.Secret = ciphertext
}
return sec, nil
}

// unmarshalSecret unmarshals the secret from the Secret struct returned from
// the Secret Service and returns the string form of the secret. If the
// receiver has an AES key, it is used to decrypt the secret in the Secret
// struct using the Param as the initialisation vector (IV) to the AES
// decryper. If the AES key in the receiver is empty, the secret is not
// decrypted. If there was an error decrypting the secret, it is returned.
func (ss *SecretService) unmarshalSecret(secret *Secret) (string, error) {
plaintext := secret.Secret
if len(ss.aesKey) > 0 {
var err error
plaintext, err = unauthenticatedAESCBCDecrypt(secret.Params, secret.Secret, ss.aesKey)
if err != nil {
return "", err
}
}
return string(plaintext), nil
}

// searchExact returns a function iterator that iterates all the items in the
// SecretService that exactly match the given attributes. This is a more strict
// search than the [SearchItems] method of the service in that the items
Expand Down
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.23
require (
github.com/alecthomas/kong v0.9.0
github.com/godbus/dbus/v5 v5.1.0
golang.org/x/crypto v0.26.0
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=

0 comments on commit ce35151

Please sign in to comment.