Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 734a651be4 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:09:57 +00:00
8 changed files with 21 additions and 70 deletions
+3 -15
View File
@@ -18,18 +18,6 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # 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 - 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 `golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (`GOOS=darwin`), with cgo off
@@ -337,9 +325,9 @@ https://git.eeqj.de/sneak/secret/milestone/12
lengths. No macOS test runs in CI. A macOS runner would cover all of it 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). (asked on https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Memory security: age writes an identity's private key out as a string in - Memory security: age identity .String() creates unprotected copies of
ordinary memory, and the copies it makes on the way stay there private keys; the call sites are listed in
(`secret.IdentityToLockedBuffer` overwrites only the string itself). https://git.eeqj.de/sneak/secret/issues/38.
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
- Graceful handling of corrupted or missing key files with recovery - Graceful handling of corrupted or missing key files with recovery
+2 -1
View File
@@ -91,7 +91,8 @@ func (cli *Instance) storeNewEncryptionKey(
return nil, fmt.Errorf("failed to generate age key: %w", err) return nil, fmt.Errorf("failed to generate age key: %w", err)
} }
secureBuffer := secret.IdentityToLockedBuffer(identity) // Store the generated key directly in a secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
err = vlt.AddSecret(secretName, secureBuffer, false) err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil { if err != nil {
-18
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"os" "os"
"syscall" "syscall"
"unsafe"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -103,23 +102,6 @@ func DecryptWithIdentity(
return resultBuffer, nil 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 // EncryptWithPassphrase encrypts data using a passphrase with age's
// scrypt-based encryption. Both data and passphrase parameters should // scrypt-based encryption. Both data and passphrase parameters should
// be LockedBuffers for secure memory handling // be LockedBuffers for secure memory handling
-29
View File
@@ -1,29 +0,0 @@
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())
}
+6 -2
View File
@@ -396,7 +396,8 @@ func deriveLongTermPrivateKey(
"failed to derive long-term key from mnemonic: %w", err) "failed to derive long-term key from mnemonic: %w", err)
} }
return IdentityToLockedBuffer(ltIdentity), nil // Return the private key in a secure buffer
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
} }
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the // CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
@@ -447,7 +448,10 @@ func CreateKeychainUnlocker(
defer agePrivKeyPassphrase.Destroy() defer agePrivKeyPassphrase.Destroy()
// Step 3: Encrypt age private key with the generated passphrase // Step 3: Encrypt age private key with the generated passphrase
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity) // Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase( encryptedAgePrivKey, err := EncryptWithPassphrase(
+3 -2
View File
@@ -330,7 +330,7 @@ func encryptPGPUnlockerKeys(
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err) return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
} }
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity) ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient( encryptedLtPrivKey, err := EncryptToRecipient(
@@ -340,7 +340,8 @@ func encryptPGPUnlockerKeys(
"failed to encrypt long-term private key to age unlocker: %w", err) "failed to encrypt long-term private key to age unlocker: %w", err)
} }
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity) // Use memguard to protect the private key in memory
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer agePrivateKeyBuffer.Destroy() defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID) encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
+3 -1
View File
@@ -175,7 +175,9 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
return fmt.Errorf("failed to generate version keypair: %w", err) return fmt.Errorf("failed to generate version keypair: %w", err)
} }
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity) // Store private key in memguard buffer immediately
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
[]byte(versionIdentity.String()))
defer versionPrivateKeyBuffer.Destroy() defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair", DebugWith("Generated version keypair",
+4 -2
View File
@@ -401,7 +401,7 @@ func (v *Vault) CreatePassphraseUnlocker(
} }
// Encrypt long-term private key to this unlocker // Encrypt long-term private key to this unlocker
ltPrivKeyBuffer := secret.IdentityToLockedBuffer(ltIdentity) ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy() defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
@@ -530,7 +530,9 @@ func (v *Vault) writeUnlockerFiles(
} }
// Encrypt private key with passphrase // Encrypt private key with passphrase
privKeyBuffer := secret.IdentityToLockedBuffer(unlockerIdentity) privKeyStr := unlockerIdentity.String()
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
defer privKeyBuffer.Destroy() defer privKeyBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase) encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)