Skip to content

Commit 371990f

Browse files
age: fix YubiKey identity persistence via raw-append (ADR-0002) (#3399)
Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>
1 parent 49abf9c commit 371990f

3 files changed

Lines changed: 151 additions & 13 deletions

File tree

internal/backend/crypto/age/commands.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,12 @@ func (l loader) Commands() []*cli.Command {
173173
}
174174
if len(recEncm) < 1 && !strings.HasPrefix(idS, "AGE-SECRET-KEY-1") {
175175
recEncm, err = termio.AskForString(ctx, "Provide the corresponding age recipient", "")
176-
if err != nil || recEncm == "" {
176+
if err != nil {
177177
return exit.Error(exit.Unknown, err, "failed to read corresponding age recipient")
178178
}
179+
if recEncm == "" {
180+
return exit.Error(exit.Usage, nil, "recipient must not be empty for plugin identities")
181+
}
179182
if strings.HasPrefix(recEncm, "AGE-") {
180183
out.Warning(ctx, "You have provided an identity as a recipient, recipients should start in 'age1', this might not be properly supported and might leak secret data in our identity recipient cache")
181184
}

internal/backend/crypto/age/identities.go

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,44 @@ func (a *Age) addIdentity(ctx context.Context, id age.Identity) error {
339339
debug.Log("error invalidating age id recipient cache: %s", err)
340340
}
341341

342-
ids, _ := a.Identities(ctx)
342+
// Read existing identity file as raw text without parsing it.
343+
// This avoids re-invoking external age plugins (e.g. age-plugin-yubikey) for
344+
// identities already in the file, which would fail if the hardware token is
345+
// unavailable or the plugin binary is missing.
346+
existing, err := a.loadIdentityFile(ctx)
347+
newFile := false
348+
if err != nil {
349+
if !errors.Is(err, os.ErrNotExist) {
350+
return fmt.Errorf("failed to read identity file: %w", err)
351+
}
352+
newFile = true
353+
}
354+
355+
// Append the new identity as a raw line, preserving all existing content.
356+
newLine := fmt.Sprintf("%s", id)
357+
var lines []string
358+
if existing == "" {
359+
lines = []string{newLine}
360+
} else {
361+
lines = append(strings.Split(strings.TrimRight(existing, "\n"), "\n"), newLine)
362+
}
363+
364+
return a.saveIdentities(ctx, lines, newFile)
365+
}
366+
367+
// loadIdentityFile decrypts and returns the raw text content of the identity file
368+
// without parsing individual identity lines. This avoids invoking external age
369+
// plugins (e.g. age-plugin-yubikey) merely to read existing file contents.
370+
func (a *Age) loadIdentityFile(ctx context.Context) (string, error) {
371+
pwcb := a.effectivePwCallback(fmt.Sprintf("to read the age keyring from %s", a.identity))
372+
ppcb := a.effectivePwPurgeCallback()
343373

344-
ids = append(ids, id)
374+
buf, err := a.decryptFile(ctx, a.identity, pwcb, ppcb)
375+
if err != nil {
376+
return "", err
377+
}
345378

346-
return a.saveIdentities(ctx, identitiesToString(ids), true)
379+
return string(buf), nil
347380
}
348381

349382
func (a *Age) saveIdentities(ctx context.Context, ids []string, newFile bool) error {
@@ -471,15 +504,6 @@ func recipientsToString(recps []age.Recipient) []string {
471504
return r
472505
}
473506

474-
func identitiesToString(ids []age.Identity) []string {
475-
r := make([]string, 0, len(ids))
476-
for _, id := range ids {
477-
r = append(r, fmt.Sprintf("%s", id))
478-
}
479-
480-
return r
481-
}
482-
483507
func modTime(path string) time.Time {
484508
fi, err := os.Stat(path)
485509
if err != nil {

internal/backend/crypto/age/identities_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package age
22

33
import (
44
"fmt"
5+
"os"
6+
"path/filepath"
57
"testing"
68

79
"filippo.io/age"
@@ -107,3 +109,112 @@ func TestIdentityAndRecipient(t *testing.T) {
107109
})
108110
}
109111
}
112+
113+
// newTestAge creates an Age instance whose identity file lives under a temp
114+
// directory and uses a fixed test passphrase so no interactive prompt is
115+
// needed during tests.
116+
func newTestAge(t *testing.T) *Age {
117+
t.Helper()
118+
td := t.TempDir()
119+
t.Setenv("GOPASS_HOMEDIR", td)
120+
ctx := t.Context()
121+
a, err := New(ctx, "")
122+
require.NoError(t, err)
123+
// Override the identity path to a known temp location.
124+
a.identity = filepath.Join(td, "age", "identities")
125+
// Use a fixed passphrase so no UI interaction is needed.
126+
a.pwCallback = func(_ string, _ bool) ([]byte, error) {
127+
return []byte("test-passphrase"), nil
128+
}
129+
a.pwPurgeCallback = func(_ string) {}
130+
131+
return a
132+
}
133+
134+
// TestAddIdentityToNewFile verifies that addIdentity works when no identity
135+
// file exists yet (i.e. creates a new file correctly).
136+
func TestAddIdentityToNewFile(t *testing.T) {
137+
ctx := t.Context()
138+
a := newTestAge(t)
139+
140+
id, err := age.GenerateX25519Identity()
141+
require.NoError(t, err)
142+
143+
require.NoError(t, a.addIdentity(ctx, id))
144+
145+
// The file should now exist and contain exactly the one identity.
146+
ids, err := a.Identities(ctx)
147+
require.NoError(t, err)
148+
require.Len(t, ids, 1)
149+
rec := IdentityToRecipient(ids[0])
150+
require.NotNil(t, rec)
151+
assert.Equal(t, id.Recipient().String(), fmt.Sprintf("%s", rec))
152+
}
153+
154+
// TestAddIdentityDoesNotParseExistingPluginLines verifies Option A: when a
155+
// plugin-format line is already present in the identity file, adding a new
156+
// native identity does NOT re-invoke the plugin binary (because we only do a
157+
// raw text append, not a parse-all-then-serialize cycle).
158+
//
159+
// We simulate this by writing a plugin-format raw line directly into the
160+
// encrypted identity file via saveIdentities, then adding a new key. If the
161+
// old code path were still active it would call parseIdentity on the plugin
162+
// line, which calls plugin.NewIdentity() and would fail without the binary.
163+
// With Option A, the plugin line is copied verbatim.
164+
func TestAddIdentityPreservesPluginLineWithoutInvokingPlugin(t *testing.T) {
165+
ctx := t.Context()
166+
a := newTestAge(t)
167+
168+
// Plant a plugin-format line (gopass custom format: identity|recipient).
169+
// This is the serialized form that saveIdentities / identitiesToString
170+
// would produce for a wrappedIdentity.
171+
pluginRaw := "AGE-PLUGIN-YUBIKEY-1GKZKJQYZL98RLMC67F9PJ|age1yubikey1qt2r3tfk7wvlykudm7ew28dqqm3h8ln9zfsxsq4lcd2w8rh4n4hhz46ur24"
172+
require.NoError(t, a.saveIdentities(ctx, []string{pluginRaw}, true))
173+
174+
// Now add a real native identity. Option A must NOT try to call
175+
// plugin.NewIdentity() on the existing pluginRaw line.
176+
newID, err := age.GenerateX25519Identity()
177+
require.NoError(t, err)
178+
require.NoError(t, a.addIdentity(ctx, newID))
179+
180+
// Read back the raw file and confirm both lines are present.
181+
raw, err := a.loadIdentityFile(ctx)
182+
require.NoError(t, err)
183+
184+
assert.Contains(t, raw, pluginRaw, "plugin line must be preserved verbatim")
185+
assert.Contains(t, raw, newID.String(), "new native identity must be appended")
186+
}
187+
188+
// TestAddMultipleIdentitiesAccumulate verifies that calling addIdentity
189+
// multiple times accumulates all identities in the file, each as its own line.
190+
func TestAddMultipleIdentitiesAccumulate(t *testing.T) {
191+
ctx := t.Context()
192+
a := newTestAge(t)
193+
194+
id1, err := age.GenerateX25519Identity()
195+
require.NoError(t, err)
196+
id2, err := age.GenerateX25519Identity()
197+
require.NoError(t, err)
198+
id3, err := age.GenerateX25519Identity()
199+
require.NoError(t, err)
200+
201+
require.NoError(t, a.addIdentity(ctx, id1))
202+
require.NoError(t, a.addIdentity(ctx, id2))
203+
require.NoError(t, a.addIdentity(ctx, id3))
204+
205+
ids, err := a.Identities(ctx)
206+
require.NoError(t, err)
207+
require.Len(t, ids, 3)
208+
}
209+
210+
// TestLoadIdentityFileNotExist verifies that loadIdentityFile returns an
211+
// os.ErrNotExist-wrapped error when the identity file has not been created yet.
212+
func TestLoadIdentityFileNotExist(t *testing.T) {
213+
a := newTestAge(t)
214+
ctx := t.Context()
215+
216+
_, err := a.loadIdentityFile(ctx)
217+
require.Error(t, err)
218+
assert.ErrorIs(t, err, os.ErrNotExist,
219+
"loadIdentityFile should surface an os.ErrNotExist-compatible error")
220+
}

0 commit comments

Comments
 (0)