Skip to content

Commit 58f99aa

Browse files
committed
objectsigner: Add context to Sign and KeyID to ssh signer
Evolve the per-signer API so signing can be cancelled and so callers can identify the signing key. - The ssh, gpg and program signers' Sign method now takes a context.Context. The program signer threads it into the external binary invocation; the ssh and gpg signers sign locally and accept the context for interface uniformity. - The ssh signer gains a KeyID method returning the SHA256 fingerprint of its public key. Tests and examples are updated for the new signature. The auto plugin, which depends on the ssh and gpg modules, will be updated once these are released. Assisted-by: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io>
1 parent 990a634 commit 58f99aa

8 files changed

Lines changed: 109 additions & 30 deletions

File tree

‎plugin/objectsigner/gpg/example_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package gpg_test
22

33
import (
4+
"context"
45
"fmt"
56
"strings"
67

@@ -21,7 +22,7 @@ func ExampleFromKey() {
2122
panic(err)
2223
}
2324

24-
sig, err := signer.Sign(strings.NewReader("signed commit message\n"))
25+
sig, err := signer.Sign(context.Background(), strings.NewReader("signed commit message\n"))
2526
if err != nil {
2627
panic(err)
2728
}

‎plugin/objectsigner/gpg/gpg.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package gpg
44

55
import (
66
"bytes"
7+
"context"
78
"errors"
89
"fmt"
910
"io"
@@ -36,8 +37,10 @@ type signer struct {
3637
}
3738

3839
// Sign reads message and returns an ASCII-armored detached GPG
39-
// signature created with the signer's OpenPGP key.
40-
func (s *signer) Sign(message io.Reader) ([]byte, error) {
40+
// signature created with the signer's OpenPGP key. The context is accepted
41+
// for interface uniformity across signers; native OpenPGP signing is purely
42+
// local and does not consult it.
43+
func (s *signer) Sign(_ context.Context, message io.Reader) ([]byte, error) {
4144
if message == nil {
4245
return nil, ErrNilMessage
4346
}

‎plugin/objectsigner/gpg/gpg_test.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gpg_test
22

33
import (
44
"bytes"
5+
"context"
56
"io"
67
"strings"
78
"testing"
@@ -62,7 +63,7 @@ func TestSign(t *testing.T) {
6263
signer, err := gpg.FromKey(generateTestKey(t))
6364
require.NoError(t, err)
6465

65-
sig, err := signer.Sign(test.message)
66+
sig, err := signer.Sign(context.Background(), test.message)
6667
if test.wantErr != "" {
6768
require.ErrorContains(t, err, test.wantErr)
6869
require.Nil(t, sig)
@@ -85,7 +86,7 @@ func TestSignVerifyRoundTrip(t *testing.T) {
8586

8687
message := "signed commit message\n"
8788

88-
sig, err := signer.Sign(strings.NewReader(message))
89+
sig, err := signer.Sign(context.Background(), strings.NewReader(message))
8990
require.NoError(t, err)
9091

9192
keyring := openpgp.EntityList{key}
@@ -106,10 +107,10 @@ func TestSignDifferentMessagesProduceDifferentSignatures(t *testing.T) {
106107
signer, err := gpg.FromKey(key)
107108
require.NoError(t, err)
108109

109-
sig1, err := signer.Sign(strings.NewReader("message one"))
110+
sig1, err := signer.Sign(context.Background(), strings.NewReader("message one"))
110111
require.NoError(t, err)
111112

112-
sig2, err := signer.Sign(strings.NewReader("message two"))
113+
sig2, err := signer.Sign(context.Background(), strings.NewReader("message two"))
113114
require.NoError(t, err)
114115

115116
assert.NotEqual(t, sig1, sig2, "different messages produced identical signatures")

‎plugin/objectsigner/program/program.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,17 +150,17 @@ func resolveProgram(program string, lookPath func(string) (string, error)) (stri
150150
}
151151

152152
// Sign reads message and returns the signature produced by the external
153-
// binary.
154-
func (s *signer) Sign(message io.Reader) ([]byte, error) {
153+
// binary. The context cancels the external program invocation.
154+
func (s *signer) Sign(ctx context.Context, message io.Reader) ([]byte, error) {
155155
if message == nil {
156156
return nil, ErrNilMessage
157157
}
158158

159159
switch s.format {
160160
case FormatOpenPGP, FormatX509:
161-
return s.signStdio(context.Background(), message)
161+
return s.signStdio(ctx, message)
162162
case FormatSSH:
163-
return s.signSSH(context.Background(), message)
163+
return s.signSSH(ctx, message)
164164
default:
165165
return nil, fmt.Errorf("%w: %q", ErrUnsupportedFormat, s.format)
166166
}

‎plugin/objectsigner/program/program_test.go‎

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,61 @@ func TestSign_NilMessage(t *testing.T) {
135135

136136
signer, _ := newTestSigner(t, FormatOpenPGP, "ABC", nil)
137137

138-
sig, err := signer.Sign(nil)
138+
sig, err := signer.Sign(context.Background(), nil)
139139
require.ErrorIs(t, err, ErrNilMessage)
140140
require.Nil(t, sig)
141141
}
142142

143+
// TestSign_ThreadsContext asserts Sign threads the caller's context into the
144+
// command invocation for every format, rather than building the command with a
145+
// fresh context.Background(). The proof is propagation: cancelling the context
146+
// passed to Sign is observable through the context the command was created
147+
// with, which is only possible if it is the same context.
148+
func TestSign_ThreadsContext(t *testing.T) {
149+
t.Parallel()
150+
151+
tests := []struct {
152+
name string
153+
format Format
154+
signingKey string
155+
}{
156+
{name: "openpgp", format: FormatOpenPGP, signingKey: "KEYID"},
157+
{name: "x509", format: FormatX509, signingKey: "KEYID"},
158+
{name: "ssh", format: FormatSSH, signingKey: "/path/to/key"},
159+
}
160+
161+
for _, test := range tests {
162+
t.Run(test.name, func(t *testing.T) {
163+
t.Parallel()
164+
165+
signer, calls := newTestSigner(t, test.format, test.signingKey, func(cmd *mockCommand) error {
166+
if test.format == FormatSSH {
167+
return writeSignatureFile(cmd.args[len(cmd.args)-1])
168+
}
169+
170+
_, err := io.WriteString(cmd.stdout, "SIG\n")
171+
require.NoError(t, err)
172+
173+
return nil
174+
})
175+
176+
ctx, cancel := context.WithCancel(context.Background())
177+
defer cancel()
178+
179+
_, err := signer.Sign(ctx, strings.NewReader("body"))
180+
require.NoError(t, err)
181+
182+
require.Len(t, calls(), 1)
183+
got := calls()[0].ctx
184+
require.NotNil(t, got)
185+
186+
require.NoError(t, got.Err())
187+
cancel()
188+
assert.ErrorIs(t, got.Err(), context.Canceled)
189+
})
190+
}
191+
}
192+
143193
func TestSign_StdioFormats(t *testing.T) {
144194
t.Parallel()
145195

@@ -163,7 +213,7 @@ func TestSign_StdioFormats(t *testing.T) {
163213
return nil
164214
})
165215

166-
sig, err := signer.Sign(strings.NewReader("commit body\n"))
216+
sig, err := signer.Sign(context.Background(), strings.NewReader("commit body\n"))
167217
require.NoError(t, err)
168218
assert.Equal(t, "STDIO-SIG\n", string(sig))
169219
assert.Equal(t, "commit body\n", stdin)
@@ -184,7 +234,7 @@ func TestSign_StdioFailure(t *testing.T) {
184234
return errStdioExit
185235
})
186236

187-
sig, err := signer.Sign(strings.NewReader("body"))
237+
sig, err := signer.Sign(context.Background(), strings.NewReader("body"))
188238
require.Error(t, err)
189239
assert.Contains(t, err.Error(), "stdio failed")
190240
require.Nil(t, sig)
@@ -206,7 +256,7 @@ func TestSign_SSH(t *testing.T) {
206256
return writeSignatureFile(bufferFile)
207257
})
208258

209-
sig, err := signer.Sign(strings.NewReader("commit body\n"))
259+
sig, err := signer.Sign(context.Background(), strings.NewReader("commit body\n"))
210260
require.NoError(t, err)
211261
assert.Equal(t, "SSH-SIG\n", string(sig))
212262
assert.Equal(t, "commit body\n", buffer)
@@ -234,7 +284,7 @@ func TestSign_SSHExpandsHomePath(t *testing.T) {
234284
return writeSignatureFile(bufferFile)
235285
})
236286

237-
sig, err := signer.Sign(strings.NewReader("commit body\n"))
287+
sig, err := signer.Sign(context.Background(), strings.NewReader("commit body\n"))
238288
require.NoError(t, err)
239289
assert.Equal(t, "SSH-SIG\n", string(sig))
240290

@@ -290,7 +340,7 @@ func TestSign_SSHLiteralKey(t *testing.T) {
290340
return writeSignatureFile(bufferFile)
291341
})
292342

293-
sig, err := signer.Sign(strings.NewReader("commit body\n"))
343+
sig, err := signer.Sign(context.Background(), strings.NewReader("commit body\n"))
294344
require.NoError(t, err)
295345
assert.Equal(t, "SSH-SIG\n", string(sig))
296346
assert.Equal(t, "commit body\n", buffer)
@@ -320,7 +370,7 @@ func TestSign_SSHFailure(t *testing.T) {
320370
return errSSHExit
321371
})
322372

323-
sig, err := signer.Sign(strings.NewReader("body"))
373+
sig, err := signer.Sign(context.Background(), strings.NewReader("body"))
324374
require.Error(t, err)
325375
assert.Contains(t, err.Error(), "ssh failed")
326376
require.Nil(t, sig)
@@ -335,7 +385,7 @@ func TestSign_SSHPathPrefixedSshDash(t *testing.T) {
335385
return writeSignatureFile(bufferFile)
336386
})
337387

338-
sig, err := signer.Sign(strings.NewReader("body"))
388+
sig, err := signer.Sign(context.Background(), strings.NewReader("body"))
339389
require.NoError(t, err)
340390
require.NotNil(t, sig)
341391

@@ -359,7 +409,7 @@ func TestSign_StdioOutputTooLarge(t *testing.T) {
359409
return nil
360410
})
361411

362-
sig, err := signer.Sign(strings.NewReader("body"))
412+
sig, err := signer.Sign(context.Background(), strings.NewReader("body"))
363413
require.ErrorIs(t, err, ErrOutputLimitExceeded)
364414
assert.Contains(t, err.Error(), "stdout")
365415
require.Nil(t, sig)
@@ -379,7 +429,7 @@ func TestSign_StderrTooLarge(t *testing.T) {
379429
return nil
380430
})
381431

382-
sig, err := signer.Sign(strings.NewReader("body"))
432+
sig, err := signer.Sign(context.Background(), strings.NewReader("body"))
383433
require.ErrorIs(t, err, ErrOutputLimitExceeded)
384434
assert.Contains(t, err.Error(), "stderr")
385435
require.Nil(t, sig)
@@ -401,12 +451,13 @@ func TestSign_SSHSignatureTooLarge(t *testing.T) {
401451
return nil
402452
})
403453

404-
sig, err := signer.Sign(strings.NewReader("body"))
454+
sig, err := signer.Sign(context.Background(), strings.NewReader("body"))
405455
require.ErrorIs(t, err, ErrSignatureTooLarge)
406456
require.Nil(t, sig)
407457
}
408458

409459
type mockCommand struct {
460+
ctx context.Context //nolint:containedctx // captured to assert Sign threads its context into the command.
410461
run func(*mockCommand) error
411462
stdin io.Reader
412463
stdout io.Writer
@@ -457,8 +508,9 @@ func stubCommand(run func(*mockCommand) error) (
457508
) {
458509
calls := make([]*mockCommand, 0, 1)
459510

460-
commandContext := func(_ context.Context, binary string, args ...string) command {
511+
commandContext := func(ctx context.Context, binary string, args ...string) command {
461512
cmd := &mockCommand{
513+
ctx: ctx,
462514
run: run,
463515
stdin: nil,
464516
stdout: nil,

‎plugin/objectsigner/ssh/example_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ssh_test
22

33
import (
4+
"context"
45
"crypto/ed25519"
56
"crypto/rand"
67
"fmt"
@@ -29,7 +30,7 @@ func ExampleFromKey() {
2930
panic(err)
3031
}
3132

32-
sig, err := signer.Sign(strings.NewReader("signed commit message\n"))
33+
sig, err := signer.Sign(context.Background(), strings.NewReader("signed commit message\n"))
3334
if err != nil {
3435
panic(err)
3536
}

‎plugin/objectsigner/ssh/ssh.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package ssh
55

66
import (
7+
"context"
78
"errors"
89
"fmt"
910
"io"
@@ -69,8 +70,10 @@ type signer struct {
6970

7071
// Sign reads the message from r and returns an armored SSH signature created
7172
// with the signer's SSH key and hash algorithm.
72-
// The signature uses the "git" namespace.
73-
func (s *signer) Sign(message io.Reader) ([]byte, error) {
73+
// The signature uses the "git" namespace. The context is accepted for
74+
// interface uniformity across signers; SSH signing is purely local and does
75+
// not consult it.
76+
func (s *signer) Sign(_ context.Context, message io.Reader) ([]byte, error) {
7477
if message == nil {
7578
return nil, ErrNilMessage
7679
}
@@ -82,3 +85,9 @@ func (s *signer) Sign(message io.Reader) ([]byte, error) {
8285

8386
return sshsig.Armor(sig), nil
8487
}
88+
89+
// KeyID returns the SHA256 fingerprint of the signer's SSH public key,
90+
// in the form "SHA256:...".
91+
func (s *signer) KeyID() string {
92+
return gossh.FingerprintSHA256(s.signer.PublicKey())
93+
}

‎plugin/objectsigner/ssh/ssh_test.go‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ssh_test
22

33
import (
4+
"context"
45
"crypto/ed25519"
56
"crypto/rand"
67
"io"
@@ -71,7 +72,7 @@ func TestSign(t *testing.T) {
7172
signer, err := ssh.FromKey(sshSigner, ssh.WithHashAlgorithm(test.algo))
7273
require.NoError(t, err)
7374

74-
sig, err := signer.Sign(test.message)
75+
sig, err := signer.Sign(context.Background(), test.message)
7576
if test.wantErr == "" {
7677
require.NoError(t, err)
7778
assert.NotEmpty(t, sig)
@@ -95,7 +96,7 @@ func TestSignVerifyRoundTrip(t *testing.T) {
9596

9697
message := "signed commit message\n"
9798

98-
sig, err := signer.Sign(strings.NewReader(message))
99+
sig, err := signer.Sign(context.Background(), strings.NewReader(message))
99100
require.NoError(t, err)
100101

101102
ssig, err := sshsig.Unarmor(sig)
@@ -113,15 +114,26 @@ func TestSignDifferentMessagesProduceDifferentSignatures(t *testing.T) {
113114
signer, err := ssh.FromKey(sshSigner)
114115
require.NoError(t, err)
115116

116-
sig1, err := signer.Sign(strings.NewReader("message one"))
117+
sig1, err := signer.Sign(context.Background(), strings.NewReader("message one"))
117118
require.NoError(t, err)
118119

119-
sig2, err := signer.Sign(strings.NewReader("message two"))
120+
sig2, err := signer.Sign(context.Background(), strings.NewReader("message two"))
120121
require.NoError(t, err)
121122

122123
assert.NotEqual(t, sig1, sig2, "different messages produced identical signatures")
123124
}
124125

126+
func TestKeyID(t *testing.T) {
127+
t.Parallel()
128+
129+
key := generateTestSigner(t)
130+
signer, err := ssh.FromKey(key)
131+
require.NoError(t, err)
132+
133+
assert.Equal(t, gossh.FingerprintSHA256(key.PublicKey()), signer.KeyID())
134+
assert.Contains(t, signer.KeyID(), "SHA256:")
135+
}
136+
125137
//nolint:ireturn // gossh.NewSignerFromKey returns gossh.Signer (interface); no concrete type is accessible
126138
func generateTestSigner(t *testing.T) gossh.Signer {
127139
t.Helper()

0 commit comments

Comments
 (0)