Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ func (a *Agent) List() ([]*agent.Key, error) {
}

func (a *Agent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) {
return a.signWithFlags(key, data, flags, "")
}

func (a *Agent) signWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags, peer string) (*ssh.Signature, error) {
slog.Debug("called signwithflags")
a.mu.Lock()
defer a.mu.Unlock()
Expand Down Expand Up @@ -200,7 +204,7 @@ func (a *Agent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.Signat
if !bytes.Equal(s.PublicKey().Marshal(), wantKey) {
continue
}
if err := a.confirmKeyUse(wantKey); err != nil {
if err := a.confirmKeyUse(wantKey, peer); err != nil {
return nil, err
}
return s.(ssh.AlgorithmSigner).SignWithAlgorithm(rand.Reader, data, alg)
Expand Down Expand Up @@ -233,7 +237,8 @@ func (a *Agent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) {
// the confirm constraint (ssh-tpm-add -c). Mirrors ssh-agent(1) behaviour.
// wantKey must be the marshalled public key with any certificate wrapper
// already stripped, matching the comparison done in SignWithFlags.
func (a *Agent) confirmKeyUse(wantKey []byte) error {
// peer (from peerChain) is appended to the prompt when non-empty.
func (a *Agent) confirmKeyUse(wantKey []byte, peer string) error {
for _, k := range a.keys {
// AgentKey() may return a certificate; unwrap it so that a key
// added both plain and as a cert is covered by either entry's
Expand All @@ -252,6 +257,9 @@ func (a *Agent) confirmKeyUse(wantKey []byte) error {
}
prompt := fmt.Sprintf("Allow use of key %s?\nKey fingerprint %s.",
k.GetDescription(), k.Fingerprint())
if peer != "" {
prompt += "\nRequested by " + peer
}
ok, err := askpass.AskPermission(prompt)
if err != nil {
slog.Info("askpass confirmation failed", slog.String("error", err.Error()))
Expand All @@ -265,8 +273,24 @@ func (a *Agent) confirmKeyUse(wantKey []byte) error {
return nil
}

func (a *Agent) serveConn(c net.Conn) {
if err := agent.ServeAgent(a, c); err != io.EOF {
// connAgent threads the per-connection peer description into Sign,
// since agent.ServeAgent does not expose the net.Conn to handlers.
type connAgent struct {
*Agent
peer string
}

func (c *connAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) {
return c.Agent.signWithFlags(key, data, 0, c.peer)
}

func (c *connAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) {
return c.Agent.signWithFlags(key, data, flags, c.peer)
}

func (a *Agent) serveConn(c *net.UnixConn) {
ca := &connAgent{Agent: a, peer: peerChain(c)}
if err := agent.ServeAgent(ca, c); err != io.EOF {
slog.Info("Agent client connection ended unsuccessfully", slog.String("error", err.Error()))
}
}
Expand Down
60 changes: 60 additions & 0 deletions agent/peer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package agent

import (
"fmt"
"net"
"os"
"strconv"
"strings"

"golang.org/x/sys/unix"
)

const maxPeerChain = 4

// peerChain describes the process at the other end of c and a few of its
// ancestors, e.g. "ssh (1234) ← git ← zsh ← kitty". Best effort; returns ""
// if SO_PEERCRED or /proc are unavailable.
func peerChain(c *net.UnixConn) string {
rc, err := c.SyscallConn()
if err != nil {
return ""
}
var ucred *unix.Ucred
if cerr := rc.Control(func(fd uintptr) {
ucred, err = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
}); cerr != nil || err != nil {
return ""
}

var parts []string
for i, pid := 0, int(ucred.Pid); i < maxPeerChain && pid > 1; i++ {
name, ppid := procStatus(pid)
if name == "" {
break
}
if i == 0 {
name = fmt.Sprintf("%s (%d)", name, pid)
}
parts = append(parts, name)
pid = ppid
}
return strings.Join(parts, " ← ")
}

// procStatus returns Name and PPid from /proc/<pid>/status.
func procStatus(pid int) (name string, ppid int) {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
return
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "Name:"); ok {
name = strings.TrimSpace(v)
} else if v, ok := strings.CutPrefix(line, "PPid:"); ok {
ppid, _ = strconv.Atoi(strings.TrimSpace(v))
return
}
}
return
}
1 change: 1 addition & 0 deletions cmd/ssh-tpm-agent/testdata/script/agent_confirm.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ exec ssh-tpm-add -c
# Approve: askpass exits 0, signing succeeds, prompt+hint are recorded
exec ssh-keygen -Y sign -n file -f .ssh/id_ecdsa.pub file_to_sign.txt
grep '^confirm Allow use of key' askpass.log
grep 'Requested by ssh-keygen' askpass.log
rm askpass.log file_to_sign.txt.sig

# Deny: sentinel file makes askpass exit 1, signing fails
Expand Down
2 changes: 2 additions & 0 deletions internal/lsm/lsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ func RestrictAgentFiles() {
"/etc/localtime",
"/dev/null",
),
// Peer process lookup for the confirm prompt
landlock.RODirs("/proc"),
// We almost always want to read the TPM
landlock.RWFiles(
"/dev/tpm0",
Expand Down
Loading