Compare commits

..
2 Commits
Author SHA1 Message Date
sneak 8848006391 Delete .tmp- leftovers of a killed command when the lock is next taken (closes #75)
check / check (push) Failing after 2s
A command killed part-way could leave a temporary file or directory of
secret.WriteFileAtomic or secret.TempDirFor, encrypted keys included,
for good. LockStateDir now empties the lock file once it holds the lock
and writes "finished" there just before releasing it. A holder that
does not find that deletes such leftovers from the state directory,
each vault, each secret and each version, the only places those helpers
make them, matching names that start with "." and hold ".tmp-". After a
command that finished nothing is searched, so the added time does not
grow with the number of secrets and versions. A test shows that
`unlocker remove` removes an unlocker directory with no metadata file.

Model: opus-5-5
2026-10-04 17:08:46 +00:00
clawbot ef79111e2e Put age identity keys into locked buffers through one function (closes #38)
check / check (push) Failing after 3s
secret.IdentityToLockedBuffer replaces the eight places that converted
an age identity's String() to bytes for a locked buffer and left the
string, which holds the private key, in ordinary memory. It moves the
string's own bytes into the buffer, which overwrites them. The copies
age makes while encoding the key remain; the function's comment says
so. TODO.md drops these places from the 1.0 memory-security entry,
along with its stale version.go reference.

Model: opus-5-5
2026-10-04 18:42:00 +02:00
8 changed files with 72 additions and 23 deletions
+17 -5
View File
@@ -40,6 +40,18 @@ Bring the repo into policy compliance in one commit:
and the command goes on. An unlocker directory with no metadata file was
already removed by `secret unlocker remove` given its directory name; a
test now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer
through `secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key
when a passphrase, PGP, keychain or Secure Enclave unlocker is created,
the new unlocker's own key, a new secret version's key, and the key
`secret encrypt` generates. Before, each place converted the string age
returns to bytes and left the string in ordinary memory. The function
moves the string's own bytes into the buffer, which overwrites them; the
copies age makes while writing the string remain, as its comment says.
The 1.0 memory-security entry below no longer lists these places,
`internal/cli/crypto.go` among them, nor `version.go:155`, which was
`internal/secret/version.go`, not `internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off
@@ -341,11 +353,11 @@ Bring the repo into policy compliance in one commit:
- Command injection: GPG key IDs passed unescaped to exec.Command
(pgpunlocker.go:323-327); data.String() passed unescaped to the
security command (keychainunlocker.go:472-476).
- Memory security: age identity .String() creates unprotected
copies (keychainunlocker.go:356, pgpunlocker.go:256,
version.go:155); age secret key held in a plain string in
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
to GPGEncryptFunc and EncryptWithPassphrase.
- Memory security: age writes an identity's private key out as a
string in ordinary memory, and the copies it makes on the way stay
there (`secret.IdentityToLockedBuffer` overwrites only the string
itself); private keys exposed via buffer.Bytes() to GPGEncryptFunc
and EncryptWithPassphrase.
- Input validation: no maximum secret size (DoS).
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
209-216); non-constant-time public key compare (vault.go:95-100).
+1 -2
View File
@@ -91,8 +91,7 @@ func (cli *Instance) storeNewEncryptionKey(
return nil, fmt.Errorf("failed to generate age key: %w", err)
}
// Store the generated key directly in a secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
secureBuffer := secret.IdentityToLockedBuffer(identity)
err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil {
+18
View File
@@ -7,6 +7,7 @@ import (
"io"
"os"
"syscall"
"unsafe"
"filippo.io/age"
"github.com/awnumar/memguard"
@@ -102,6 +103,23 @@ func DecryptWithIdentity(
return resultBuffer, nil
}
// IdentityToLockedBuffer returns the private key of id, in age's text form, in
// a new locked buffer. The caller must destroy it.
//
// This is best effort. age gives the key only as a string in ordinary memory.
// The bytes of that string are moved into the buffer, which overwrites them,
// although Go otherwise never changes a string; nothing else holds this one.
// The copies age makes while building the string are left in ordinary memory.
// Avoiding those would mean encoding the key here, straight into the buffer.
func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer {
key := id.String()
//nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes
keyBytes := unsafe.Slice(unsafe.StringData(key), len(key))
return memguard.NewBufferFromBytes(keyBytes)
}
// EncryptWithPassphrase encrypts data using a passphrase with age's
// scrypt-based encryption. Both data and passphrase parameters should
// be LockedBuffers for secure memory handling
+29
View File
@@ -0,0 +1,29 @@
package secret_test
import (
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
// private key, and that the identity still gives that key afterwards: the
// helper overwrites the string age returned, so age must not keep it.
func TestIdentityToLockedBuffer(t *testing.T) {
t.Parallel()
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
buffer := secret.IdentityToLockedBuffer(identity)
defer buffer.Destroy()
parsed, err := age.ParseX25519Identity(buffer.String())
require.NoError(t, err)
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
assert.Equal(t, identity.String(), buffer.String())
}
+2 -6
View File
@@ -396,8 +396,7 @@ func deriveLongTermPrivateKey(
"failed to derive long-term key from mnemonic: %w", err)
}
// Return the private key in a secure buffer
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
return IdentityToLockedBuffer(ltIdentity), nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
@@ -448,10 +447,7 @@ func CreateKeychainUnlocker(
defer agePrivKeyPassphrase.Destroy()
// Step 3: Encrypt age private key with the generated passphrase
// Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(
+2 -3
View File
@@ -330,7 +330,7 @@ func encryptPGPUnlockerKeys(
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
}
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient(
@@ -340,8 +340,7 @@ func encryptPGPUnlockerKeys(
"failed to encrypt long-term private key to age unlocker: %w", err)
}
// Use memguard to protect the private key in memory
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
+1 -3
View File
@@ -175,9 +175,7 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
return fmt.Errorf("failed to generate version keypair: %w", err)
}
// Store private key in memguard buffer immediately
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
[]byte(versionIdentity.String()))
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair",
+2 -4
View File
@@ -401,7 +401,7 @@ func (v *Vault) CreatePassphraseUnlocker(
}
// Encrypt long-term private key to this unlocker
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
ltPrivKeyBuffer := secret.IdentityToLockedBuffer(ltIdentity)
defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
@@ -530,9 +530,7 @@ func (v *Vault) writeUnlockerFiles(
}
// Encrypt private key with passphrase
privKeyStr := unlockerIdentity.String()
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
privKeyBuffer := secret.IdentityToLockedBuffer(unlockerIdentity)
defer privKeyBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)