Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 2s

script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
This commit was merged in pull request #96.
This commit is contained in:
2026-10-04 18:07:56 +02:00
parent 1cc8653981
commit 017b8d73bf
19 changed files with 1056 additions and 769 deletions
+57 -67
View File
@@ -1,5 +1,4 @@
//go:build darwin
// +build darwin
package secret
@@ -13,7 +12,6 @@ import (
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -32,6 +30,7 @@ const (
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"`
}
@@ -43,6 +42,19 @@ type SecureEnclaveUnlocker struct {
fs afero.Fs
}
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
@@ -58,6 +70,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil {
return nil, fmt.Errorf(
@@ -140,7 +153,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash)
if err := macse.DeleteKey(seKeyHash); err != nil {
err = macse.DeleteKey(seKeyHash)
if err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err)
@@ -148,7 +163,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
}
Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
err = RemoveDirAtomic(s.fs, s.Directory)
if err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
}
@@ -158,34 +175,24 @@ func (s *SecureEnclaveUnlocker) Remove() error {
}
// getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
}
var seMetadata SecureEnclaveUnlockerMetadata
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
err = json.Unmarshal(metadataData, &seMetadata)
if err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
}
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
}
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname()
@@ -214,7 +221,8 @@ func CreateSecureEnclaveUnlocker(
stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil {
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
@@ -231,6 +239,7 @@ func CreateSecureEnclaveUnlocker(
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
@@ -263,14 +272,14 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, "macos"},
Flags: []string{seUnlockerType, macOSFlag},
},
SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash,
@@ -283,20 +292,7 @@ func CreateSecureEnclaveUnlocker(
// Step 5: Write the SE-encrypted long-term key, then the metadata
err = WriteDir(fs, unlockerDir, func(dir string) error {
ltKeyPath := filepath.Join(dir, seLongtermFilename)
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
})
if err != nil {
return nil, err
@@ -309,6 +305,29 @@ func CreateSecureEnclaveUnlocker(
}, nil
}
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
// SE-encrypted long-term key, then the metadata.
func writeSEUnlockerFiles(
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
encryptedLtKey)
if err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker.
@@ -318,37 +337,7 @@ func getLongTermKeyForSE(
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
mnemonic.String(),
metadata.DerivationIndex,
)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
}
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
currentUnlocker, err := vault.GetCurrentUnlocker()
@@ -373,6 +362,7 @@ func getLongTermKeyForSE(
currentUnlocker.GetDirectory(),
"longterm.age",
)
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil {
return nil, fmt.Errorf(