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
4 changes: 3 additions & 1 deletion plugins/pass/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ func (m *PassValue) Marshal() ([]byte, error) {
}

func (m *PassValue) Unmarshal(data []byte) error {
m.value = data
// Copy: store backends zero the source buffer after Unmarshal returns
// (see store/keychain and store/posixage), so we must not alias it.
m.value = append([]byte(nil), data...)
return nil
}

Expand Down
36 changes: 36 additions & 0 deletions plugins/pass/store/store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package store

import (
"testing"

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

// TestPassValueUnmarshalCopies guards the contract that store backends zero
// the source buffer after Unmarshal returns. PassValue must own its bytes.
func TestPassValueUnmarshalCopies(t *testing.T) {
src := []byte("hunter2")
pv := &PassValue{}
require.NoError(t, pv.Unmarshal(src))

clear(src)

out, err := pv.Marshal()
require.NoError(t, err)
assert.Equal(t, []byte("hunter2"), out)
}
4 changes: 3 additions & 1 deletion store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ var (
type Secret interface {
// Marshal the secret into a slice of bytes
Marshal() ([]byte, error)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[HIGH] Incomplete interface contract: Marshal() return value is also zeroed by backends

All four store backends call defer clear(data) on the slice returned by Marshal():

// keychain_darwin.go:183, keychain_linux.go:322, keychain_windows.go:63, posixage/store.go:351
data, err := secret.Marshal()
defer clear(data)  // ← zeroes m.value in PassValue via the alias

Since PassValue.Marshal() returns m.value directly (return m.value, nil) without copying, every Save()/Upsert() call silently zeroes the PassValue's internal state. Any subsequent Marshal() call on the same instance returns all-zero bytes.

The new comment added in this PR documents that Unmarshal implementations must copy retained bytes, but misses the symmetric contract for Marshal: backends zero the returned buffer, so Marshal() must return a fresh copy (or the contract must state that Marshal() output will be zeroed and callers must not reuse the instance).

Suggested fixes:

Option A — Copy in PassValue.Marshal():

func (m *PassValue) Marshal() ([]byte, error) {
    return append([]byte(nil), m.value...), nil
}

Option B — Document the contract on the Marshal interface comment (alongside the Unmarshal note already added):

// Marshal the secret into a slice of bytes.
// Callers (store backends) may zero the returned buffer after use;
// implementations that need to retain state must not return an alias.
Marshal() ([]byte, error)

// Unmarshal the secret from a slice of bytes into its structured format
// Unmarshal the secret from a slice of bytes into its structured format.
// Implementations must copy any bytes they retain: store backends zero
// the source buffer after Unmarshal returns to avoid leaking plaintext.
Unmarshal(data []byte) error
// Metadata returns a key-value pair of non-sensitive data about the secret
Metadata() map[string]string
Expand Down
Loading