2 Commits
Author SHA1 Message Date
sneak 16c2e0c018 Add the README's required sections and clear stale TODO.md items (closes #46)
check / check (push) Failing after 2s
README gains Description, Getting Started, Rationale, Design, TODO and
License sections; its first sentence names the licence and author.
Installation and Quick Start become Getting Started; Core Architecture
becomes Design, whose two false version bullets (symlink switching,
unencrypted metadata) are corrected. README and AGENTS.md are wrapped
to prettier's settings.

TODO.md: Workflow and Next Step point at the 1.0.0 milestone and the
next branch, the old Next Step's four finished items move to Completed
Steps with their dates, and Future Steps loses the items already done.

Model: opus-5-5
2026-10-04 16:43:34 +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 70 additions and 21 deletions
+15 -3
View File
@@ -18,6 +18,18 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps
- 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
@@ -325,9 +337,9 @@ https://git.eeqj.de/sneak/secret/milestone/12
lengths. No macOS test runs in CI. A macOS runner would cover all of it
(asked on https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md):
- Memory security: age identity .String() creates unprotected copies of
private keys; the call sites are listed in
https://git.eeqj.de/sneak/secret/issues/38.
- 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).
- Medium priority:
- Standardize error messages; stop leaking internals.
- Graceful handling of corrupted or missing key files with recovery
+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)