diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4d855d3..53ea8e65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,7 @@ jobs: echo "::error::This is the Ubuntu mirror, not snug. Re-run the job." exit 1 fi - sudo timeout 120 apt-get install -y bubblewrap + sudo timeout 120 apt-get install -y bubblewrap gnupg # pasta comes from UPSTREAM, not from apt, and that is not a preference. # diff --git a/docs/src/identity.md b/docs/src/identity.md index e2c4161c..7f477465 100644 --- a/docs/src/identity.md +++ b/docs/src/identity.md @@ -67,10 +67,10 @@ include = ["work", "kubectl"] a different cluster definition. `kubectl` inside sees one context, and the other cluster is not there at all. -`identity` itself holds up to four optional blocks: `ssh`, `git`, `gh` and -`kubectl`. Blocks from different profiles combine, but each block has one value -per run. A second, different value of the same block is refused, naming both -profiles: +`identity` itself holds up to five optional blocks: `ssh`, `git`, `gpg`, `gh` +and `kubectl`. Blocks from different profiles combine, but each block has one +value per run. A second, different value of the same block is refused, naming +both profiles: ```text $ snug -p work -p other ~/src/app @@ -102,9 +102,9 @@ With the proxy, the generated git config also rewrites `https://HOST/` to | `signing_key` | path to the **public** key used for `gpg.format = ssh` signing | snug generates `~/.gitconfig` from this block and points `GIT_CONFIG_GLOBAL` -at it. It never reads the host git config. snug generates it when an `ssh` or -`git` block is set and without either there is no `~/.gitconfig`, and `git commit` -inside has no author. +at it. It never reads the host git config. snug generates it when an `ssh`, +`git` or `gpg` block is set and without any of them there is no `~/.gitconfig`, +and `git commit` inside has no author. With `signing_key`, snug generates the signing configuration and turns commit signing on. The private key stays in your host agent, so it needs @@ -116,6 +116,10 @@ it. `email` must be a single address. +A `sign`-scoped [`identity.gpg`](#identitygpg) block also turns commit signing on, +in OpenPGP format. Because git signs in ONE format, a profile cannot pin both an +ssh `signing_key` and a sign-scoped gpg key: snug refuses that. + #### Other git settings A profile is not a configuration surface for the programs inside, so @@ -131,6 +135,69 @@ ro = ["{home}/.config/snug/sandbox.gitconfig:/etc/gitconfig"] git reads it below the generated `~/.gitconfig`, so the pinned keys still win. `~/.gitconfig` and `~/.config/git/config` do not work for this. +### `identity.gpg` + +| key | value | default | +|---|---|---| +| `fingerprint` | the key's fingerprint, 40 or 64 hex characters | none | +| `scope` | array of `"sign"`, `"decrypt"` | none — required | + +```toml +[profile.work] + + [profile.work.identity.gpg] + fingerprint = "6732361300BBFDAE7F22972CD4381E1CE2880244" + scope = ["sign"] +``` + +`GNUPGHOME` inside points at a `~/.gnupg` snug generates, whose `S.gpg-agent` +socket is a filtering proxy to your host gpg-agent. Only the pinned key is +usable, and no key material enters the sandbox: your other keys can be neither +used nor listed, and `gpg --export-secret-keys` returns nothing. + +The key is named by **fingerprint**, not by path — the private half lives in +your host agent and is matched there, so snug reads no key file for this block. +There is no `agent` knob: unlike ssh, where `"none"` is a real mode with keys of +its own on the sandbox disk, a pinned gpg key is useless without the agent, so a +block that names a fingerprint has already selected the proxy. + +`scope` says what the sandbox may do with the key, and must be set: + +| value | grants | +|---|---| +| `"sign"` | sign with the key (`gpg --detach-sign`, and `git commit -S` — snug turns OpenPGP commit signing on) | +| `"decrypt"` | decrypt a message addressed to the key | + +`scope = ["sign", "decrypt"]` grants both. snug resolves the keygrips from the +fingerprint, and pins **subkeys only**: + +- `"sign"` pins the key's signing subkeys. The primary key is never pinned, + because it also certifies: through it the sandbox could add a user ID to your + key, bind a subkey it holds, or revoke the key, and all of those outlive the + run. A key without a signing subkey is refused. Add one on the host with + `gpg --quick-add-key ed25519 sign`. +- `"decrypt"` pins the encryption subkeys, expired ones included, so mail + already addressed to them still decrypts. A key without one is refused. + +There is no `"encrypt"` value, deliberately: encrypting **to** the key uses only +the public half, needs no agent, and so grants nothing a sandbox does not +already have. There is no `"auth"` value either: gpg-agent's ssh-emulation side +is a protocol snug has not measured, and a value whose verbs are unenforced +would be a scope in name only. + +A `"decrypt"` grant cannot sign, and a `"sign"` grant cannot decrypt. The agent +keeps one selected key per connection whichever verb selected it, so the proxy +also checks that the signing or decrypting command follows a selection made +for that purpose. + +A hostile process inside the sandbox can use this block to sign anything as you +with the pinned signing subkey, and to decrypt anything addressed to the pinned +encryption subkey that it can read, for as long as the run lasts. It cannot +certify, list or use your other keys, export key material, or decide where a +passphrase prompt appears or what it says. A prompt for a sandbox request names +snug and the keygrip, and appears wherever your agent shows prompts, never on +the terminal the sandbox shares with you. + ### `identity.gh` | key | value | default | diff --git a/docs/src/reference/environment-names.md b/docs/src/reference/environment-names.md index 692d4dad..b04fb33b 100644 --- a/docs/src/reference/environment-names.md +++ b/docs/src/reference/environment-names.md @@ -75,6 +75,7 @@ reads them in place of what snug wrote. |---|---|---| | `podman` is `"socket"` or `"build"` | `CONTAINER_HOST` `DOCKER_BUILDKIT` `DOCKER_HOST` | `CONTAINER_CONNECTION` | | `identity.ssh.agent` is `"proxy"` | `SSH_AUTH_SOCK` | | +| an `identity.gpg` block | `GNUPGHOME` | | | `identity.gh.user` is set | `GH_CONFIG_DIR` `GH_HOST` | `GH_ENTERPRISE_TOKEN` `GH_TOKEN` `GITHUB_ENTERPRISE_TOKEN` `GITHUB_TOKEN` | | an `identity.ssh` or `identity.git` block | `GIT_CONFIG_GLOBAL` | | | an `identity.kubectl` block | `KUBECONFIG` | | diff --git a/docs/src/reference/profile-keys.md b/docs/src/reference/profile-keys.md index 73e586ff..bc0f0db5 100644 --- a/docs/src/reference/profile-keys.md +++ b/docs/src/reference/profile-keys.md @@ -29,6 +29,8 @@ Every key a `[profile.NAME]` table accepts. Any other key is an error. | `identity.git.name` | string | author name | the block must match | [Identity](../identity.md#identitygit) | | `identity.git.email` | string | author email | the block must match | [Identity](../identity.md#identitygit) | | `identity.git.signing_key` | string | path to a public key | the block must match | [Identity](../identity.md#identitygit) | +| `identity.gpg.fingerprint` | string | 40- or 64-hex key fingerprint | the block must match | [Identity](../identity.md#identitygpg) | +| `identity.gpg.scope` | array of strings | `["sign"]`, `["decrypt"]`, both | union | [Identity](../identity.md#identitygpg) | | `identity.gh.host` | string | host name | the block must match | [Identity](../identity.md#identitygh) | | `identity.gh.user` | string | account name | the block must match | [Identity](../identity.md#identitygh) | | `identity.kubectl.context` | string | context name in the kubeconfig | the block must match | [Identity](../identity.md#identitykubectl) | diff --git a/internal/cli/config.go b/internal/cli/config.go index 4056a1b1..2b0efb80 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -1077,6 +1077,24 @@ func showIdentity(id *policy.Identity, show func(string, []string)) { "agent protocol carries no purpose, so either pinned key can be used "+ "for either job.")...) } + if id.GPG.Fingerprint != "" { + // One block per scope, because they are two grants with two blast + // radii, and the proxy holds each to its own verb. + row := "gpg key " + visibleValue(id.GPG.Fingerprint) + if policy.GPGScopeHas(id.GPG.Scope, policy.GPGScopeSign) { + rows = append(rows, capRows(row+" (sign)", + "THE SANDBOX CAN SIGN AS YOU with this OpenPGP key — commits, tags, release "+ + "artifacts, mail — for as long as the run lasts. snug generates "+ + "commit.gpgsign = true, so commits inside are signed by default. No private "+ + "key enters the sandbox and your other keys cannot be listed or used.")...) + } + if policy.GPGScopeHas(id.GPG.Scope, policy.GPGScopeDecrypt) { + rows = append(rows, capRows(row+" (decrypt)", + "THE SANDBOX CAN DECRYPT ANYTHING ADDRESSED TO THIS KEY that it can read, "+ + "for as long as the run lasts. The proxy sees an opaque ciphertext and "+ + "cannot tell which message it is decrypting.")...) + } + } if id.Git.Name != "" || id.Git.Email != "" { rows = append(rows, strings.TrimSpace("git "+visibleValue(id.Git.Name)+ " <"+visibleValue(id.Git.Email)+">")) diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index 9c7de372..43922573 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -97,6 +97,7 @@ func renderHuman(out io.Writer, rep Report, p *policy.Policy, args []string, cfg describeGrafts(out, rep, p) describeContainers(out, p, rep.Containers) describeCommitSigning(out, p) + describeGPG(out, rep.GPG) describeSSH(out, p) describeKube(out, rep.Kube) describeCommands(out, p, env) diff --git a/internal/cli/dryrunjson.go b/internal/cli/dryrunjson.go index 9aa714c8..fe646315 100644 --- a/internal/cli/dryrunjson.go +++ b/internal/cli/dryrunjson.go @@ -369,7 +369,9 @@ type jsonDoc struct { // BrowserBridge is absent when the policy's login set lacks claude. BrowserBridge *jsonBrowserBridge `json:"browser_bridge,omitempty"` // Kube is absent when no identity.kubectl block is pinned. - Kube *jsonKube `json:"kube,omitempty"` + Kube *jsonKube `json:"kube,omitempty"` + // GPG is absent when no identity.gpg block is pinned. + GPG *jsonGPG `json:"gpg,omitempty"` Topology jsonTopology `json:"topology"` Containers *jsonContainers `json:"containers"` Environment []jsonEnvVar `json:"environment"` @@ -904,6 +906,9 @@ func (e *lossyEncoder) document(rep Report) jsonDoc { Proxy: k.Proxy, Refused: k.Refused, } } + if g := rep.GPG; g != nil { + doc.GPG = &jsonGPG{Fingerprint: g.Fingerprint, Scope: g.Scope, Proxy: g.Proxy} + } doc.Target, doc.TargetBytes = e.text(rep.Target) if rep.TargetAsked != "" { doc.TargetAsked, doc.TargetAskedBytes = e.text(rep.TargetAsked) @@ -922,7 +927,7 @@ func (e *lossyEncoder) document(rep Report) jsonDoc { // The fact behind the human column's "exec" word: a KindData file // with an executable permission bit is CODE, not config (the // podman stub is the one case today). - Executable: m.Perms != nil && *m.Perms&0o111 != 0, + Executable: m.Kind == policy.KindData && m.Perms != nil && *m.Perms&0o111 != 0, HostIsRunScoped: m.RunScoped, } if m.Kind == policy.KindTmpfs { diff --git a/internal/cli/gpgdryrun.go b/internal/cli/gpgdryrun.go new file mode 100644 index 00000000..2214f12d --- /dev/null +++ b/internal/cli/gpgdryrun.go @@ -0,0 +1,58 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/gomoni/snug/internal/policy" +) + +// reportGPG is what identity.gpg grants, for both --dry-run screens. The +// FILESYSTEM rows show a socket and a keyring; neither says WHICH host key +// answers on that socket or what the sandbox may do with it, and a sign grant +// and a decrypt grant would otherwise read the same. +type reportGPG struct { + Fingerprint string + Scope []string // "sign", "decrypt", in that order + Proxy string // the socket's guest path +} + +func buildGPGReport(p *policy.Policy) *reportGPG { + if !p.Identity.Pinned("gpg") { + return nil + } + r := &reportGPG{ + Fingerprint: p.Identity.GPG.Fingerprint, + Proxy: p.Home + "/" + policy.GPGAgentSocketGuest, + } + for _, s := range []string{policy.GPGScopeSign, policy.GPGScopeDecrypt} { + if policy.GPGScopeHas(p.Identity.GPG.Scope, s) { + r.Scope = append(r.Scope, s) + } + } + return r +} + +// jsonGPG is reportGPG. +type jsonGPG struct { + Fingerprint string `json:"fingerprint"` + Scope []string `json:"scope"` + Proxy string `json:"proxy_guest"` +} + +func describeGPG(out io.Writer, g *reportGPG) { + if g == nil { + return + } + fmt.Fprintf(out, "GPG ~/.gnupg/S.gpg-agent is a filtering proxy to your host gpg-agent\n") + fmt.Fprintf(out, " key %s, the only key listed or usable\n", + policy.VisibleText(g.Fingerprint)) + for _, s := range g.Scope { + switch s { + case policy.GPGScopeSign: + fmt.Fprintf(out, " sign anything, as you, while the run lasts\n") + case policy.GPGScopeDecrypt: + fmt.Fprintf(out, " decrypt anything addressed to the key that the sandbox can read\n") + } + } +} diff --git a/internal/cli/gpgidentity.go b/internal/cli/gpgidentity.go new file mode 100644 index 00000000..07c8d36b --- /dev/null +++ b/internal/cli/gpgidentity.go @@ -0,0 +1,366 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/gomoni/snug/internal/gpgproxy" + "github.com/gomoni/snug/internal/policy" +) + +// gpgGrips is one pinned key as the host agent knows it: the keygrips of the +// subkeys that sign and of those that decrypt, each 40 hex characters. +// +// The PRIMARY key's grip is deliberately not among them. The primary carries +// Certify, and the agent signs a hash without knowing what it is for, so a +// pinned primary is a certification oracle: measured through this proxy, +// `gpg --quick-add-uid 'Evil '` inside the sandbox +// added a user ID certified by the host's primary key — a change to the key +// itself that outlives the run. The same PKSIGN can bind a subkey the attacker +// holds or make a revocation. Subkeys cannot certify. +// +// Both lists can hold several grips, for two measured reasons. A key can carry +// more than one subkey of a kind, and gpg uses the newest, so pinning only the +// first would leave the one actually used unpinned. And a gpg 2.5 +// post-quantum subkey (ky768_bp256) is composite: its `grp:` line carries TWO +// grips, comma-separated, and decryption names both (`SETKEY ` then +// `SETKEY --another `). +type gpgGrips struct { + signers []string // signing-capable, unrevoked, unexpired subkeys' grips + encryptors []string // encryption subkeys' grips, expired ones included +} + +// gpgKeyInfo asks the host gpg for the keygrips behind a fingerprint. +// +// `gpg --with-colons --with-keygrip --list-secret-keys ` is the +// one host touch of the gpg half, and it is deliberately a READ: snug never +// copies key material. The `grp:` lines carry the keygrips the AGENT uses, and +// the `fpr:` lines that follow a `sec:`/`ssb:` record say which key each grip +// belongs to. +// +// A fingerprint with no SECRET key is refused rather than staged: the sandbox +// could still encrypt to the public half, but it could not sign or decrypt, and +// a gpg block that grants neither is a pin that does not exist. The refusal +// names the fingerprint and says what to do. +func gpgKeyInfo(fingerprint string) (gpgGrips, error) { + gpg, err := exec.LookPath("gpg") + if err != nil { + return gpgGrips{}, fmt.Errorf("identity.gpg needs gpg on the host to resolve the key's " + + "keygrips, and no gpg is on PATH") + } + ctx, cancel := context.WithTimeout(context.Background(), gpgHostTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, gpg, "--batch", "--with-colons", "--with-keygrip", + "--list-secret-keys", "--", fingerprint) + // The HOST's own GNUPGHOME, if the caller has one set (inherited): this + // runs before the sandbox exists. + var out, errs bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errs + if err := cmd.Run(); err != nil { + return gpgGrips{}, fmt.Errorf("identity.gpg.fingerprint %q: `gpg --list-secret-keys` "+ + "failed: %v: %s", fingerprint, err, strings.TrimSpace(errs.String())) + } + grips, ok := parseKeygrips(out.String(), fingerprint) + if !ok { + return gpgGrips{}, fmt.Errorf("identity.gpg.fingerprint %q: the host gpg has no SECRET "+ + "key with that fingerprint.\n\n"+ + " The sandbox signs and decrypts THROUGH the host agent, so the private\n"+ + " half must be on this host and available to gpg. Check `gpg --list-secret-keys`\n"+ + " on the host, or correct the fingerprint (40 hex characters, the string\n"+ + " `gpg --fingerprint` prints).", fingerprint) + } + return grips, nil +} + +// parseKeygrips reads the colon-delimited listing of ONE key and returns its +// subkeys' grips, and whether the listing held the asked-for key at all. +// +// It is a small parser ON PURPOSE: it consumes only `sec:`, `ssb:`, `fpr:` and +// `grp:`, keys signing and encryption off the capability field of `ssb:` (the +// 12th: `s`, `e`), and reads only the block whose primary `fpr:` is the one +// asked for, so a listing holding a second key adds nothing. Validity (the 2nd +// field) keeps a revoked or expired subkey from SIGNING; it still decrypts, +// because mail already addressed to it is the reason to keep it. +func parseKeygrips(listing, fingerprint string) (gpgGrips, bool) { + var g gpgGrips + var ( + inKey bool // inside the asked-for key's block + atSec bool // the next fpr: is the primary's + found bool + validity string + caps string + ) + want := strings.ToUpper(fingerprint) + for line := range strings.SplitSeq(listing, "\n") { + fields := strings.Split(line, ":") + field := func(n int) string { + if len(fields) > n { + return fields[n] + } + return "" + } + switch fields[0] { + case "sec": + inKey, atSec, caps = false, true, "" + case "ssb": + atSec = false + validity, caps = field(1), field(11) + case "fpr": + if atSec { + inKey = strings.ToUpper(field(9)) == want + found = found || inKey + atSec = false + } + case "grp": + if !inKey || caps == "" || field(9) == "" { + continue + } + grips := strings.Split(strings.ToUpper(field(9)), ",") + if strings.Contains(caps, "s") && validity != "r" && validity != "e" { + g.signers = append(g.signers, grips...) + } + if strings.Contains(caps, "e") { + g.encryptors = append(g.encryptors, grips...) + } + } + } + return g, found +} + +// gpgPins is the pin set for one key and scope. A scope the key has no subkey +// for is refused rather than pinned to nothing: a profile that grants signing +// on a key that cannot sign through snug would run, and every `git commit` +// inside would fail with no cause on screen. +func gpgPins(fingerprint, scope string, g gpgGrips) ([]gpgproxy.PinnedGrip, error) { + var pins []gpgproxy.PinnedGrip + if policy.GPGScopeHas(scope, policy.GPGScopeSign) { + if len(g.signers) == 0 { + return nil, fmt.Errorf("identity.gpg.fingerprint %q has no signing subkey, and "+ + "snug signs only with a subkey.\n\n"+ + " The primary key also CERTIFIES: through it the sandbox could add a user\n"+ + " ID, bind a subkey or revoke the key, and those outlive the run. Add a\n"+ + " signing subkey on the host: gpg --quick-add-key %s ed25519 sign", + fingerprint, fingerprint) + } + for _, grip := range g.signers { + pins = append(pins, gpgproxy.PinnedGrip{ + Field: "identity.gpg.fingerprint", Grip: grip, Scope: gpgproxy.ScopeSign}) + } + } + if policy.GPGScopeHas(scope, policy.GPGScopeDecrypt) { + if len(g.encryptors) == 0 { + return nil, fmt.Errorf("identity.gpg.fingerprint %q has no encryption subkey, so "+ + "scope \"decrypt\" has nothing to decrypt with.\n"+ + " Drop \"decrypt\" from identity.gpg.scope, or add one on the host:\n"+ + " gpg --quick-add-key %s cv25519 encr", fingerprint, fingerprint) + } + for _, grip := range g.encryptors { + pins = append(pins, gpgproxy.PinnedGrip{ + Field: "identity.gpg.fingerprint", Grip: grip, Scope: gpgproxy.ScopeDecrypt}) + } + } + return pins, nil +} + +// startGPGIdentity is what identity.gpg needs from the host: the Assuan proxy +// to the host gpg-agent and the pinned key's PUBLIC keyring. +// +// ~/.gnupg itself (a 0700 tmpfs) and GNUPGHOME are Resolve's; this stages what +// needs host state inside it. +// +// It is gated by its caller on the presence of the block (id.GPG is not the +// zero value) rather than on a mode, because the block IS the proxy. +func startGPGIdentity(pol *policy.Policy, id *policy.Identity, verbose, dryRun bool) (cleanup func(), err error) { + cleanup = func() {} + + var sock string + if dryRun { + sock, err = plannedSocket("gpg-agent.sock") + if err != nil { + return nil, err + } + } else { + rt, rerr := openRuntimeDir() + if rerr != nil { + return nil, rerr + } + sock, err = rt.Socket("gpg-agent.sock") + if err != nil { + return nil, err + } + } + + if dryRun { + // The public keyring is staged here too, so --dry-run shows the row a + // real run mounts. It is public material read from the host gpg; a host + // that does not know the key is a warning on a dry run, as a missing gh + // token is, because a dry run inspects a policy on hosts where the real + // run may never happen. + if keyring, kerr := gpgExportPublicKey(id.GPG.Fingerprint); kerr != nil { + fmt.Fprintln(os.Stderr, "snug: "+kerr.Error()) + fmt.Fprintln(os.Stderr, " (dry run: continuing, and the FILESYSTEM block below "+ + "therefore has no pubring.kbx row)") + } else { + stageGPGKeyring(pol, keyring) + } + pol.BindSocket(sock, pol.Home+"/"+policy.GPGAgentSocketGuest, + identityProvenance(pol, "gpg")) + return cleanup, nil + } + + grips, err := gpgKeyInfo(id.GPG.Fingerprint) + if err != nil { + cleanup() + return nil, err + } + + if _, err := gpgScopeFor(id.GPG.Scope); err != nil { + cleanup() + return nil, err + } + pinned, err := gpgPins(id.GPG.Fingerprint, id.GPG.Scope, grips) + if err != nil { + cleanup() + return nil, err + } + + upstream, err := gpgAgentSocket() + if err != nil { + cleanup() + return nil, err + } + + audit := func(string) {} + if verbose { + audit = func(msg string) { fmt.Fprintln(os.Stderr, "snug: gpg-agent: "+msg) } + } + slow := time.AfterFunc(2*time.Second, func() { + fmt.Fprintln(os.Stderr, "snug: waiting for the host gpg-agent to answer a startup probe.\n"+ + " If a pinned key needs a passphrase or confirmation, your agent is asking for it.") + }) + p, perr := gpgproxy.New(pinned, upstream, sock, audit) + slow.Stop() + if perr != nil { + cleanup() + return nil, perr + } + go p.Serve() + inner := cleanup + cleanup = func() { p.Close(); inner() } + + // The pinned PUBLIC keyring, so gpg inside knows the key it may act on. + // Only public material; the private half stays in the host agent. + keyring, kerr := gpgExportPublicKey(id.GPG.Fingerprint) + if kerr != nil { + cleanup() + return nil, kerr + } + stageGPGKeyring(pol, keyring) + + pol.BindSocket(sock, pol.Home+"/"+policy.GPGAgentSocketGuest, + identityProvenance(pol, "gpg")) + return cleanup, nil +} + +// stageGPGKeyring mounts the pinned key's public keyring read-only. +func stageGPGKeyring(pol *policy.Policy, keyring []byte) { + pol.Replace(policy.Mount{ + Guest: pol.Home + "/" + policy.GPGKeyringGuest, Kind: policy.KindData, + Access: policy.AccessRO, Content: keyring, + From: []string{identityProvenance(pol, "gpg")}, + }) +} + +// gpgScopeFor maps the profile's canonical scope spelling to the proxy's bit +// set. It is the ONE place the two vocabularies meet: internal/policy carries a +// comparable string leaf, internal/gpgproxy a bitmask, and neither imports the +// other. +// +// A spelling that maps to no bit is refused rather than passed on: gpgproxy.New +// already refuses a zero scope, but this refusal names the PROFILE field rather +// than the proxy's constructor. +func gpgScopeFor(scope string) (gpgproxy.Scope, error) { + var out gpgproxy.Scope + for _, part := range strings.Split(scope, ",") { + switch part { + case "": + continue + case policy.GPGScopeSign: + out |= gpgproxy.ScopeSign + case policy.GPGScopeDecrypt: + out |= gpgproxy.ScopeDecrypt + default: + // An unknown part is refused HERE as well as in + // ParseGPGRuntimeScope: this function maps the two vocabularies, and + // a mapping that silently dropped a word it did not know would turn a + // scope snug cannot enforce into one it can. Two doors, one answer. + return 0, fmt.Errorf("unknown identity.gpg.scope %q (want %s or %s)", + part, policy.GPGScopeSign, policy.GPGScopeDecrypt) + } + } + if out == 0 { + return 0, fmt.Errorf("identity.gpg.scope %q grants nothing; "+ + "want %s or %s, or both", scope, policy.GPGScopeSign, policy.GPGScopeDecrypt) + } + return out, nil +} + +// gpgAgentSocket is the host's gpg-agent socket, from gpgconf, which is the +// only authority on where it lives (it depends on GNUPGHOME, which the caller +// may or may not have set). Each failure names its own cause: "start an agent" +// is the wrong advice when gpgconf itself is missing. +func gpgAgentSocket() (string, error) { + gpgconf, err := exec.LookPath("gpgconf") + if err != nil { + return "", errors.New("identity.gpg needs gpgconf on the host to find the gpg-agent " + + "socket, and no gpgconf is on PATH") + } + ctx, cancel := context.WithTimeout(context.Background(), gpgHostTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, gpgconf, "--list-dirs", "agent-socket").Output() + if err != nil { + return "", fmt.Errorf("identity.gpg: `gpgconf --list-dirs agent-socket` failed: %v", err) + } + path := strings.TrimSpace(string(out)) + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("identity.gpg needs a running host gpg-agent, and there is no "+ + "socket at %s.\n\n"+ + " Start one with `gpg-connect-agent /bye` or let gpg start it, then retry.", path) + } + return path, nil +} + +// gpgHostTimeout bounds each host gpg/gpgconf call. They read a keyring and +// print; none of them prompts. +const gpgHostTimeout = 30 * time.Second + +// gpgExportPublicKey is `gpg --export `: the PUBLIC half only, the +// bytes the sandbox's keyring holds. It never asks for a secret key. +func gpgExportPublicKey(fingerprint string) ([]byte, error) { + gpg, err := exec.LookPath("gpg") + if err != nil { + return nil, fmt.Errorf("identity.gpg needs gpg on the host, and none is on PATH") + } + ctx, cancel := context.WithTimeout(context.Background(), gpgHostTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, gpg, "--batch", "--export", "--", fingerprint) + var out, errs bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errs + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("identity.gpg.fingerprint %q: `gpg --export` failed: %v: %s", + fingerprint, err, strings.TrimSpace(errs.String())) + } + if out.Len() == 0 { + return nil, fmt.Errorf("identity.gpg.fingerprint %q: `gpg --export` produced no public "+ + "key; the host gpg does not know it", fingerprint) + } + return out.Bytes(), nil +} diff --git a/internal/cli/gpgidentity_test.go b/internal/cli/gpgidentity_test.go new file mode 100644 index 00000000..ea073880 --- /dev/null +++ b/internal/cli/gpgidentity_test.go @@ -0,0 +1,382 @@ +package cli + +import ( + "os" + "slices" + "strings" + "testing" + + "github.com/gomoni/snug/internal/gpgproxy" + "github.com/gomoni/snug/internal/policy" +) + +const ( + fprA = "6732361300BBFDAE7F22972CD4381E1CE2880244" + fprB = "0E1D0C4D9A2B7E3B3F0B6D4F05B63E8E4E213D24" + // Grips, distinct per role so a wrong pick shows in the diff. + gripAPrimary = "2761D2BF75F0095F0FEA0A9216F4114A3B0C20BA" + gripAEncr = "80C95D6427C02FBB78C91DCEE6E12BD520EF66F0" + gripASign = "1111111111111111111111111111111111111111" + gripBPrimary = "31ABBA59A26690C3264EAFC95372EDF59E25F3E4" + gripBSign = "2222222222222222222222222222222222222222" + gripBEncr = "3333333333333333333333333333333333333333" +) + +// keyAListing is laid out like `gpg --with-colons --with-keygrip +// --list-secret-keys` for an ed25519 primary with a signing subkey and a +// cv25519 encryption subkey. The fingerprints and grips are fixtures in gpg's +// field order, not a capture; TestHostGPGEndToEnd parses a real one. +const keyAListing = `sec:u:255:22:D4381E1CE2880244:1792588754:::u:::cC:::+::ed25519:::0: +fpr:::::::::` + fprA + `: +grp:::::::::` + gripAPrimary + `: +ssb:u:255:22:BBBBBBBBBBBBBBBB:1792588754::::::s:::+::ed25519:: +fpr:::::::::AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: +grp:::::::::` + gripASign + `: +ssb:u:255:18:A53647A6FF8D6256:1792588754::::::e:::+::cv25519:: +fpr:::::::::12BB70F3FB52237528EC3804A53647A6FF8D6256: +grp:::::::::` + gripAEncr + `: +` + +func TestParseKeygripsPinsSubkeysAndNeverThePrimary(t *testing.T) { + g, ok := parseKeygrips(keyAListing, fprA) + if !ok { + t.Fatal("parseKeygrips found no key in a real-shaped listing") + } + if len(g.signers) != 1 || g.signers[0] != gripASign { + t.Errorf("signers = %q, want [%s]", g.signers, gripASign) + } + if len(g.encryptors) != 1 || g.encryptors[0] != gripAEncr { + t.Errorf("encryptors = %q, want [%s]", g.encryptors, gripAEncr) + } + // The primary certifies: through a pinned primary the sandbox could add a + // user ID to the host key or bind a subkey it holds. It is in neither list. + for _, list := range [][]string{g.signers, g.encryptors} { + if slices.Contains(list, gripAPrimary) { + t.Errorf("the primary's grip %s is pinned: %q", gripAPrimary, list) + } + } +} + +// A listing holding a second key adds nothing to the first, in either +// direction, and a fingerprint absent from the listing reports so. +func TestParseKeygripsReadsOnlyTheAskedForKey(t *testing.T) { + listing := keyAListing + `sec:u:255:22:05B63E8E4E213D24:1:::u:::cC:::+::ed25519:::0: +fpr:::::::::` + fprB + `: +grp:::::::::` + gripBPrimary + `: +ssb:u:255:22:CCCCCCCCCCCCCCCC:1::::::s:::+::ed25519:: +grp:::::::::` + gripBSign + `: +ssb:u:255:18:DDDDDDDDDDDDDDDD:1::::::e:::+::cv25519:: +grp:::::::::` + gripBEncr + `: +` + a, ok := parseKeygrips(listing, fprA) + if !ok || strings.Join(a.signers, ",") != gripASign || strings.Join(a.encryptors, ",") != gripAEncr { + t.Errorf("key A = %+v (ok %v), want only its own subkeys", a, ok) + } + b, ok := parseKeygrips(listing, fprB) + if !ok || strings.Join(b.signers, ",") != gripBSign || strings.Join(b.encryptors, ",") != gripBEncr { + t.Errorf("key B = %+v (ok %v), want only its own subkeys", b, ok) + } + if g, ok := parseKeygrips(listing, "9999999999999999999999999999999999999999"); ok || len(g.signers)+len(g.encryptors) != 0 { + t.Errorf("an absent fingerprint produced %+v (ok %v)", g, ok) + } + // gpg prints fingerprints upper-case; a profile may spell them either way. + if _, ok := parseKeygrips(listing, strings.ToLower(fprB)); !ok { + t.Error("a lower-case fingerprint did not match") + } +} + +// A revoked or expired signing subkey must not be pinned for signing: the +// agent would refuse it, and the startup probe would refuse the run for a key +// the user cannot use. An expired ENCRYPTION subkey is kept, because mail +// already addressed to it is the reason the key is wanted. +func TestParseKeygripsDropsDeadSigningSubkeys(t *testing.T) { + const live = "4444444444444444444444444444444444444444" + listing := `sec:u:255:22:AAAAAAAAAAAAAAAA:1:::u:::cC:::+::ed25519:::0: +fpr:::::::::` + fprA + `: +grp:::::::::` + gripAPrimary + `: +ssb:r:255:22:BBBBBBBBBBBBBBBB:1::::::s:::+::ed25519:: +grp:::::::::` + gripASign + `: +ssb:e:255:22:CCCCCCCCCCCCCCCC:1::::::s:::+::ed25519:: +grp:::::::::2222222222222222222222222222222222222222: +ssb:u:255:22:DDDDDDDDDDDDDDDD:1::::::s:::+::ed25519:: +grp:::::::::` + live + `: +ssb:e:255:18:EEEEEEEEEEEEEEEE:1::::::e:::+::cv25519:: +grp:::::::::` + gripAEncr + `: +` + g, ok := parseKeygrips(listing, fprA) + if !ok { + t.Fatal("no key") + } + if len(g.signers) != 1 || g.signers[0] != live { + t.Errorf("signers = %q, want only the live subkey %s", g.signers, live) + } + if len(g.encryptors) != 1 || g.encryptors[0] != gripAEncr { + t.Errorf("encryptors = %q, want the expired encryption subkey kept", g.encryptors) + } +} + +// A subkey that is neither signing nor encrypting (authentication) pins nothing, +// and one that is both lands in both lists. +func TestParseKeygripsFollowsTheCapabilityField(t *testing.T) { + listing := `sec:u:255:22:AAAAAAAAAAAAAAAA:1:::u:::cC:::+::ed25519:::0: +fpr:::::::::` + fprA + `: +grp:::::::::` + gripAPrimary + `: +ssb:u:255:22:BBBBBBBBBBBBBBBB:1::::::a:::+::ed25519:: +grp:::::::::5555555555555555555555555555555555555555: +ssb:u:3072:1:CCCCCCCCCCCCCCCC:1::::::se:::+::rsa3072:: +grp:::::::::6666666666666666666666666666666666666666: +` + g, _ := parseKeygrips(listing, fprA) + if strings.Join(g.signers, ",") != "6666666666666666666666666666666666666666" || + strings.Join(g.encryptors, ",") != "6666666666666666666666666666666666666666" { + t.Errorf("got %+v, want the se subkey in both lists and the auth subkey in neither", g) + } +} + +// A gpg 2.5 post-quantum subkey is composite and its `grp:` line carries two +// grips, and decryption names both: `SETKEY ` then `SETKEY --another +// `. The shape is gpg 2.5's; the grips here are fixtures. +func TestParseKeygripsSplitsACompositeSubkey(t *testing.T) { + pqc := `sec:u:384:19:05B63E8E4E213D24:1792675586:::u:::scESC:::+::brainpoolP384r1:23::0: +fpr:::::::::` + fprB + `: +grp:::::::::` + gripBPrimary + `: +ssb:u:768:8:AE689B81586D7A5D:1792675586::::::e:::+::ky768_bp256:8 23: +grp:::::::::6BB91A8AD3741EF3E274773A21E79A14C7CC6A70,3B6B6986FE3B419F7FCCBE4721C56547A740F67E: +` + g, ok := parseKeygrips(pqc, fprB) + if !ok { + t.Fatal("no key") + } + want := []string{"6BB91A8AD3741EF3E274773A21E79A14C7CC6A70", "3B6B6986FE3B419F7FCCBE4721C56547A740F67E"} + if strings.Join(g.encryptors, ",") != strings.Join(want, ",") { + t.Errorf("encryptors = %q, want %q", g.encryptors, want) + } + if len(g.signers) != 0 { + t.Errorf("signers = %q, want none: the primary is not pinned and the subkey does not sign", g.signers) + } +} + +// Every encryption subkey is pinned, not the first: gpg encrypts to the newest, +// so a key with an old and a new subkey has its mail addressed to the second. +func TestParseKeygripsTakesEveryEncryptionSubkey(t *testing.T) { + two := `sec:u:255:22:AAAAAAAAAAAAAAAA:1:::u:::cC:::+::ed25519:::0: +fpr:::::::::` + fprA + `: +grp:::::::::1111111111111111111111111111111111111111: +ssb:e:255:18:BBBBBBBBBBBBBBBB:1::::::e:::+::cv25519:: +grp:::::::::2222222222222222222222222222222222222222: +ssb:u:255:18:CCCCCCCCCCCCCCCC:2::::::e:::+::cv25519:: +grp:::::::::3333333333333333333333333333333333333333: +` + g, _ := parseKeygrips(two, fprA) + if len(g.encryptors) != 2 || g.encryptors[1] != "3333333333333333333333333333333333333333" { + t.Errorf("encryptors = %q, want both subkeys' grips", g.encryptors) + } +} + +// A key with no secret half: `--list-secret-keys` prints nothing (or only +// stderr), so the parser finds no key and the caller refuses. +func TestParseKeygripsRefusesAnEmptyListing(t *testing.T) { + if _, ok := parseKeygrips("", fprA); ok { + t.Error("an empty listing reported a key") + } +} + +func scopeOf(pins []gpgproxy.PinnedGrip, grip string) gpgproxy.Scope { + var s gpgproxy.Scope + for _, p := range pins { + if p.Grip == grip { + s |= p.Scope + } + } + return s +} + +// TestGPGPinsFollowTheScope: sign pins the signing subkeys for signing, decrypt +// pins the encryption subkeys for decrypting, and neither pins the primary or +// crosses over. Under scope sign alone the encryption subkey is not pinned at +// all, so it is not even listed to the sandbox. +func TestGPGPinsFollowTheScope(t *testing.T) { + g := gpgGrips{signers: []string{gripASign}, encryptors: []string{gripAEncr}} + cases := []struct { + scope string + wantSign, wantDec gpgproxy.Scope + }{ + {"sign", gpgproxy.ScopeSign, 0}, + {"decrypt", 0, gpgproxy.ScopeDecrypt}, + {"decrypt,sign", gpgproxy.ScopeSign, gpgproxy.ScopeDecrypt}, + } + for _, c := range cases { + pins, err := gpgPins(fprA, c.scope, g) + if err != nil { + t.Errorf("scope %q: %v", c.scope, err) + continue + } + if got := scopeOf(pins, gripASign); got != c.wantSign { + t.Errorf("scope %q: signing subkey scope = %v, want %v", c.scope, got, c.wantSign) + } + if got := scopeOf(pins, gripAEncr); got != c.wantDec { + t.Errorf("scope %q: encryption subkey scope = %v, want %v", c.scope, got, c.wantDec) + } + if got := scopeOf(pins, gripAPrimary); got != 0 { + t.Errorf("scope %q: the primary is pinned with %v", c.scope, got) + } + } +} + +// TestGPGPinsRefuseAScopeWithNothingToUse: a sign scope on a key without a +// signing subkey must not fall back to the primary, and a decrypt scope without +// an encryption subkey grants nothing. Each refusal names the key and the +// command that fixes it, because the alternative is a run where every commit +// fails with no cause on screen. +func TestGPGPinsRefuseAScopeWithNothingToUse(t *testing.T) { + onlyEncr := gpgGrips{encryptors: []string{gripAEncr}} + _, err := gpgPins(fprA, "sign", onlyEncr) + if err == nil { + t.Fatal("scope sign was accepted on a key with no signing subkey") + } + for _, want := range []string{fprA, "gpg --quick-add-key " + fprA + " ed25519 sign", "CERTIFIES"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal lacks %q: %v", want, err) + } + } + // decrypt,sign on the same key: the sign half still refuses. + if _, err := gpgPins(fprA, "decrypt,sign", onlyEncr); err == nil { + t.Error("decrypt,sign was accepted on a key with no signing subkey") + } + + onlySign := gpgGrips{signers: []string{gripASign}} + _, err = gpgPins(fprA, "decrypt", onlySign) + if err == nil { + t.Fatal("scope decrypt was accepted on a key with no encryption subkey") + } + for _, want := range []string{fprA, "gpg --quick-add-key " + fprA + " cv25519 encr", "identity.gpg.scope"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal lacks %q: %v", want, err) + } + } + // Sign alone on that key is fine. + if _, err := gpgPins(fprA, "sign", onlySign); err != nil { + t.Errorf("scope sign on a signing key was refused: %v", err) + } +} + +func TestGPGScopeForMapsBothSpellings(t *testing.T) { + cases := []struct { + scope string + want gpgproxy.Scope + }{ + {"sign", gpgproxy.ScopeSign}, + {"decrypt", gpgproxy.ScopeDecrypt}, + {"decrypt,sign", gpgproxy.ScopeSign | gpgproxy.ScopeDecrypt}, + } + for _, c := range cases { + got, err := gpgScopeFor(c.scope) + if err != nil { + t.Errorf("gpgScopeFor(%q) refused: %v", c.scope, err) + continue + } + if got != c.want { + t.Errorf("gpgScopeFor(%q) = %v, want %v", c.scope, got, c.want) + } + } + // A scope that maps to nothing is a refusal naming the profile field. + for _, bad := range []string{"", "encrypt", "decrypt,encrypt"} { + _, err := gpgScopeFor(bad) + if err == nil { + t.Errorf("gpgScopeFor(%q) accepted a grantless scope", bad) + continue + } + if !strings.Contains(err.Error(), "identity.gpg.scope") { + t.Errorf("refusal for %q does not name the field: %v", bad, err) + } + } +} + +// gpgIdentityPolicy is the smallest policy that reaches startGPGIdentity's +// dry-run arm. +func gpgIdentityPolicy() *policy.Policy { + return &policy.Policy{ + Home: "/home/u", + Mounts: map[string]policy.Mount{}, + Env: map[string]policy.EnvVar{}, + IdentityOwners: map[string][]policy.ProfileName{"gpg": {"pinned"}}, + Identity: &policy.Identity{ + GPG: policy.IdentityGPG{ + Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", + Scope: "decrypt,sign", + }, + }, + } +} + +// TestGPGIdentityDryRunNamesTheSocketAndStartsNothing is issue #21's rule +// applied to the gpg half: a dry run binds no socket and creates no runtime +// directory — it NAMES the path a real run would use, at exactly +// $GNUPGHOME/S.gpg-agent, because that is the only path gpg looks at. +// +// GNUPGHOME and ~/.gnupg are Resolve's, not this function's (see +// policy's TestGPGIdentityOwnsItsHome), so this test does not look for them. +// The host gpg is pointed at an empty throwaway home, so the keyring row is +// absent for lack of the key rather than for what the machine running the +// suite happens to hold. +func TestGPGIdentityDryRunNamesTheSocketAndStartsNothing(t *testing.T) { + t.Setenv("GNUPGHOME", shortGnupgHome(t)) + rt := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", rt) + pol := gpgIdentityPolicy() + cleanup, err := startGPGIdentity(pol, pol.Identity, false, true) + if err != nil { + t.Fatalf("startGPGIdentity dry run: %v", err) + } + defer cleanup() + + wantGuest := pol.Home + "/" + policy.GPGAgentSocketGuest + bolt, ok := pol.Mounts[wantGuest] + if !ok { + t.Fatalf("no socket mount at %s; mounts = %v", wantGuest, pol.Mounts) + } + if bolt.Kind != policy.KindBind { + t.Errorf("socket Kind = %v, want KindBind", bolt.Kind) + } + if _, err := os.Stat(bolt.Host); err == nil { + t.Errorf("a dry run created the socket %s", bolt.Host) + } + if entries, _ := os.ReadDir(rt); len(entries) != 0 { + t.Errorf("a dry run created %d entries in the runtime directory", len(entries)) + } + if _, ok := pol.Mounts[pol.Home+"/"+policy.GPGKeyringGuest]; ok { + t.Error("a keyring was staged from a host gpg that does not know the key") + } +} + +// TestHostGPGToolsAreNamedWhenMissing: with no gpg or gpgconf on PATH each +// host touch refuses with a message naming the missing tool, not a bare exec +// error. PATH is an empty directory, so the result does not depend on what the +// machine running the suite has installed. +func TestHostGPGToolsAreNamedWhenMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if _, err := gpgKeyInfo(fprA); err == nil || !strings.Contains(err.Error(), "no gpg is on PATH") { + t.Errorf("gpgKeyInfo without gpg: %v", err) + } + if _, err := gpgExportPublicKey(fprA); err == nil || !strings.Contains(err.Error(), "none is on PATH") { + t.Errorf("gpgExportPublicKey without gpg: %v", err) + } + if _, err := gpgAgentSocket(); err == nil || !strings.Contains(err.Error(), "no gpgconf is on PATH") { + t.Errorf("gpgAgentSocket without gpgconf: %v", err) + } +} + +// TestGPGAgentSocketGuestIsWhereGPGLooks pins the spelling: gpg derives its +// agent socket as $GNUPGHOME/S.gpg-agent, so the guest path must be exactly +// that relative to GPGHomeGuest. A change here would make gpg talk to a socket +// that does not exist. +func TestGPGAgentSocketGuestIsWhereGPGLooks(t *testing.T) { + if policy.GPGAgentSocketGuest != policy.GPGHomeGuest+"/S.gpg-agent" { + t.Errorf("GPGAgentSocketGuest = %q, want %q/S.gpg-agent", + policy.GPGAgentSocketGuest, policy.GPGHomeGuest) + } + if policy.GPGKeyringGuest != policy.GPGHomeGuest+"/pubring.kbx" { + t.Errorf("GPGKeyringGuest = %q, want %q/pubring.kbx", + policy.GPGKeyringGuest, policy.GPGHomeGuest) + } +} diff --git a/internal/cli/gpgidentitygolden_test.go b/internal/cli/gpgidentitygolden_test.go new file mode 100644 index 00000000..29caaf87 --- /dev/null +++ b/internal/cli/gpgidentitygolden_test.go @@ -0,0 +1,247 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/gomoni/snug/internal/engine" + "github.com/gomoni/snug/internal/policy" + "github.com/gomoni/snug/internal/profile" +) + +const goldenGPGFingerprint = "6732361300BBFDAE7F22972CD4381E1CE2880244" + +// gpgGoldenPolicy resolves the builtin defaults plus one profile pinning +// goldenGPGFingerprint with the given scope, and stages what a dry run stages: +// the agent socket from startGPGIdentity and, standing in for the host's +// `gpg --export`, a fixed keyring. The host gpg is pointed at an empty +// throwaway GNUPGHOME so nothing on the machine running the suite is read. +func gpgGoldenPolicy(t *testing.T, scope string) *policy.Policy { + t.Helper() + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv("GNUPGHOME", shortGnupgHome(t)) + reg, err := profile.Builtins() + if err != nil { + t.Fatal(err) + } + reg["signer"] = &policy.Profile{Name: "signer", Identity: &policy.Identity{ + GPG: policy.IdentityGPG{Fingerprint: goldenGPGFingerprint, Scope: scope}}} + sel := append(append([]policy.ProfileName{}, profile.BuiltinDefaults()...), "signer") + p, err := policy.Resolve(map[policy.ProfileName]*policy.Profile(reg), sel, envGoldenCtx(), newEnvFakeEnv()) + if err != nil { + t.Fatalf("Resolve(%v): %v", sel, err) + } + cleanup, err := startGPGIdentity(p, p.Identity, false, true) + if err != nil { + t.Fatalf("startGPGIdentity: %v", err) + } + t.Cleanup(cleanup) + // What a real run adds once the host gpg exports the key. The dry run + // above could not, and said so on stderr. + stageGPGKeyring(p, []byte("PUBLIC-KEYRING-FIXTURE")) + p.InstallAnchors() + if err := p.Validate(newEnvFakeEnv()); err != nil { + t.Fatalf("Validate after staging: %v", err) + } + return p +} + +// shortGnupgHome is an empty 0700 directory under /tmp: gpg refuses a +// homedir with loose permissions and a long path would pass the socket limit. +func shortGnupgHome(t *testing.T) string { + t.Helper() + d, err := os.MkdirTemp("", "gh") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(d) }) + return d +} + +// TestGoldenGPGArgv is the review artifact for identity.gpg's bwrap argv: the +// 0700 tmpfs at ~/.gnupg, the proxy socket and the PUBLIC keyring inside it, +// GNUPGHOME, and — for a sign scope only — the generated gitconfig. It would +// show a diff if the host's ~/.gnupg, a private key path, or a secret keyring +// ever appeared. The pubring bytes are a fixture; what the host gpg would +// export is covered by TestHostGPGEndToEnd. +func TestGoldenGPGArgv(t *testing.T) { + cases := []struct { + name, scope, golden string + gitconfig bool + }{ + {"sign+decrypt", "decrypt,sign", "identity-gpg.bwrap.txt", true}, + {"decrypt only", "decrypt", "identity-gpg-decrypt.bwrap.txt", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := gpgGoldenPolicy(t, tc.scope) + got := runDirPID.ReplaceAllString(goldenFormat(p.BwrapArgs(1000, 1000)), "run-N") + + for _, want := range []string{"--perms 0700\n--size 1073741824\n--tmpfs /home/u/.gnupg\n", "GNUPGHOME", + "/home/u/.gnupg/S.gpg-agent", "/home/u/.gnupg/pubring.kbx"} { + if !strings.Contains(got, want) { + t.Errorf("argv lacks %q:\n%s", want, got) + } + } + // The keyring is data behind an fd: the host's own ~/.gnupg is not + // bound by any line, and no line names a private key store. + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "private-keys") || strings.Contains(line, "secring") || + strings.Contains(line, "trustdb") { + t.Errorf("argv names key material: %q", line) + } + } + if has := strings.Contains(got, "/home/u/.gitconfig"); has != tc.gitconfig { + t.Errorf("generated ~/.gitconfig present = %v, want %v for scope %q", has, tc.gitconfig, tc.scope) + } + checkGolden(t, tc.golden, got, "this is the argv for a sandbox that may use one OpenPGP key through a filtering agent proxy") + }) + } +} + +// TestGoldenGPGDryRunBlocks is what a human reads before trusting identity.gpg: +// the GPG block naming the key and what each scope lets the sandbox do, and +// the FILESYSTEM block showing the 0700 ~/.gnupg, the socket and the keyring. +// A sign grant and a decrypt grant are different abuse sentences, so both are +// pinned, and the decrypt-only screen must not claim signing. +func TestGoldenGPGDryRunBlocks(t *testing.T) { + for _, tc := range []struct{ name, scope, golden string }{ + {"sign+decrypt", "decrypt,sign", "gpg.dryrun.txt"}, + {"decrypt only", "decrypt", "gpg-decrypt.dryrun.txt"}, + } { + t.Run(tc.name, func(t *testing.T) { + p := gpgGoldenPolicy(t, tc.scope) + var b bytes.Buffer + describeGPG(&b, buildGPGReport(p)) + block := b.String() + if !strings.Contains(block, goldenGPGFingerprint) { + t.Errorf("GPG block does not name the pinned key:\n%s", block) + } + if strings.Contains(block, "sign ") != strings.Contains(tc.scope, "sign") { + t.Errorf("GPG block's sign line disagrees with scope %q:\n%s", tc.scope, block) + } + + screen := captureFile(t, func(f io.Writer) { + dryRun(newEnvFakeEnv(), f, p, []string{"/bin/sh"}, config{}, nil, nil) + }) + fs := strings.TrimRight(blockBetween(t, screen, "FILESYSTEM", "NOT GRANTED"), " \n") + "\n" + for _, want := range []string{".gnupg", "S.gpg-agent", "pubring.kbx"} { + if !strings.Contains(fs, want) { + t.Errorf("FILESYSTEM block lacks %q:\n%s", want, fs) + } + } + if !strings.Contains(screen, "\nGPG ") { + t.Errorf("the dry-run screen has no GPG block:\n%s", screen) + } + checkGolden(t, tc.golden, runDirPID.ReplaceAllString(block, "run-N"), + "this is what a human reads to decide whether a profile may sign or decrypt as them") + checkGolden(t, strings.Replace(tc.golden, ".dryrun.txt", ".filesystem.txt", 1), + runDirPID.ReplaceAllString(fs, "run-N"), "this is the FILESYSTEM block of a sandbox with a gpg proxy") + }) + } +} + +// TestGoldenShowGPGCapabilities is `profile show` for a gpg pin: one row per +// scope, each naming what that scope lets the sandbox do. It reproduces the +// render closure config.go builds, as TestGoldenShowIdentityCapabilities does. +func TestGoldenShowGPGCapabilities(t *testing.T) { + prof := &policy.Profile{Name: "signer", Identity: &policy.Identity{ + GPG: policy.IdentityGPG{Fingerprint: goldenGPGFingerprint, Scope: "decrypt,sign"}}} + var b strings.Builder + show := func(label string, vals []string) { + for i, v := range vals { + head := "" + if i == 0 { + head = label + } + fmt.Fprintf(&b, " %-16s %s\n", head, visibleValue(v)) + } + } + showCapabilities(prof, show) + got := b.String() + for _, want := range []string{"gpg key " + goldenGPGFingerprint + " (sign)", + "gpg key " + goldenGPGFingerprint + " (decrypt)"} { + if !strings.Contains(got, want) { + t.Errorf("profile show lacks the row %q:\n%s", want, got) + } + } + checkGolden(t, "show-identity-gpg.capabilities.txt", got, + "this is the screen a human reads to decide whether to select a profile that may sign or decrypt as them") +} + +// TestDryRunJSONMarksOnlyDataFilesExecutable: the 0700 on the generated +// ~/.gnupg tmpfs carries an execute bit, and "executable" is the machine +// format's word for staged CODE. Read off Perms alone, it flagged a directory +// as code. Positive control: the ~/.gnupg mount IS in the document with kind +// tmpfs, so the false could not come from the mount being absent. +func TestDryRunJSONMarksOnlyDataFilesExecutable(t *testing.T) { + p := gpgGoldenPolicy(t, "decrypt,sign") + env := newEnvFakeEnv() + rep := buildReport(env, p, p.BwrapArgs(0, 0), config{json: true}, nil, + func() engine.SignaturePolicySummary { return engine.SignaturePolicySummary{} }) + rep.Seccomp = jsonGoldenSeccomp + var buf bytes.Buffer + if err := renderJSON(&buf, rep); err != nil { + t.Fatalf("renderJSON: %v", err) + } + var doc struct { + Mounts []struct { + Guest string `json:"guest"` + Kind string `json:"kind"` + Executable bool `json:"executable"` + } `json:"mounts"` + } + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("not JSON: %v\n%s", err, buf.String()) + } + found := false + for _, m := range doc.Mounts { + if m.Guest == "/home/u/.gnupg" { + found = true + if m.Kind != "tmpfs" { + t.Errorf("~/.gnupg kind = %q, want tmpfs", m.Kind) + } + if m.Executable { + t.Error("the 0700 tmpfs at ~/.gnupg is reported executable") + } + } + } + if !found { + t.Fatalf("control: ~/.gnupg is not among the document's mounts: %+v", doc.Mounts) + } +} + +// TestDryRunJSONCarriesTheGPGGrant: the human screen's GPG block names the key +// and the scope, so the --json document must too — a CI gate cannot assert on a +// fact only the human screen carries. +func TestDryRunJSONCarriesTheGPGGrant(t *testing.T) { + for _, tc := range []struct { + scope string + want []string + }{ + {"decrypt,sign", []string{"sign", "decrypt"}}, + {"decrypt", []string{"decrypt"}}, + } { + p := gpgGoldenPolicy(t, tc.scope) + var b bytes.Buffer + if err := renderJSON(&b, Report{Outcome: "ok", GPG: buildGPGReport(p)}); err != nil { + t.Fatal(err) + } + var doc struct { + GPG *jsonGPG `json:"gpg"` + } + if err := json.Unmarshal(b.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if doc.GPG == nil || doc.GPG.Fingerprint != goldenGPGFingerprint || + strings.Join(doc.GPG.Scope, ",") != strings.Join(tc.want, ",") || + !strings.HasSuffix(doc.GPG.Proxy, "/.gnupg/S.gpg-agent") { + t.Errorf("scope %q: json gpg = %+v", tc.scope, doc.GPG) + } + } +} diff --git a/internal/cli/gpgidentityhost_test.go b/internal/cli/gpgidentityhost_test.go new file mode 100644 index 00000000..18354a8b --- /dev/null +++ b/internal/cli/gpgidentityhost_test.go @@ -0,0 +1,142 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "strings" + "testing" + + "github.com/gomoni/snug/internal/policy" +) + +// TestHostGPGEndToEnd runs the host half of identity.gpg against a real gpg and +// gpg-agent in a throwaway GNUPGHOME: gpgKeyInfo parses a real listing, +// gpgPins keeps the primary out, startGPGIdentity starts the proxy and stages +// the PUBLIC keyring, and the proxy then answers on the socket it bound. It +// would catch the parser drifting from what gpg prints, which the fixture +// listings in gpgidentity_test.go cannot. The throwaway agent is killed on +// cleanup and ~/.gnupg is never touched. +// +// What it does not cover: a sandbox. The socket is dialled from here. +func TestHostGPGEndToEnd(t *testing.T) { + for _, bin := range []string{"gpg", "gpgconf", "gpg-agent"} { + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("%s not on PATH", bin) + } + } + home := shortGnupgHome(t) + t.Setenv("GNUPGHOME", home) + rt, err := os.MkdirTemp("", "rt") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(rt) }) + t.Setenv("XDG_RUNTIME_DIR", rt) + + gpg := func(args ...string) string { + t.Helper() + cmd := exec.Command("gpg", append([]string{"--batch", "--pinentry-mode", "loopback", + "--passphrase", ""}, args...)...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Run(); err != nil { + t.Fatalf("gpg %v: %v\n%s", args, err, errb.String()) + } + return out.String() + } + t.Cleanup(func() { + exec.Command("gpgconf", "--kill", "gpg-agent").Run() + exec.Command("gpgconf", "--remove-socketdir").Run() + }) + + gpg("--quick-gen-key", "Pinned ", "ed25519", "cert", "never") + var fpr string + for line := range strings.SplitSeq(gpg("--with-colons", "--list-keys"), "\n") { + if f := strings.Split(line, ":"); f[0] == "fpr" && len(f) > 9 { + fpr = f[9] + break + } + } + if fpr == "" { + t.Fatal("no fingerprint for the generated key") + } + gpg("--quick-add-key", fpr, "ed25519", "sign", "never") + gpg("--quick-add-key", fpr, "cv25519", "encr", "never") + + g, err := gpgKeyInfo(fpr) + if err != nil { + t.Fatalf("gpgKeyInfo: %v", err) + } + if len(g.signers) != 1 || len(g.encryptors) != 1 { + t.Fatalf("grips = %+v, want one signing and one encryption subkey", g) + } + // The primary's grip is the first grp: line of the listing; pins must not + // contain it. The control is that the listing does have one. + primary := "" + for line := range strings.SplitSeq(gpg("--with-colons", "--with-keygrip", "--list-secret-keys", "--", fpr), "\n") { + if f := strings.Split(line, ":"); f[0] == "grp" && len(f) > 9 { + primary = f[9] + break + } + } + if primary == "" { + t.Fatal("control: the real listing has no primary grip") + } + pins, err := gpgPins(fpr, "decrypt,sign", g) + if err != nil { + t.Fatalf("gpgPins: %v", err) + } + for _, p := range pins { + if strings.EqualFold(p.Grip, primary) { + t.Errorf("the primary's grip %s was pinned", primary) + } + } + + // A key the host does not hold is refused, naming the fingerprint. + const unknown = "0000000000000000000000000000000000000001" + if _, err := gpgKeyInfo(unknown); err == nil || !strings.Contains(err.Error(), unknown) { + t.Errorf("an unknown fingerprint was not refused by name: %v", err) + } + + // A key with no signing subkey is refused at startup under scope sign. + gpg("--quick-gen-key", "Certify only ", "ed25519", "cert", "never") + var certFpr string + for line := range strings.SplitSeq(gpg("--with-colons", "--list-keys", "--", "cert@example.org"), "\n") { + if f := strings.Split(line, ":"); f[0] == "fpr" && len(f) > 9 { + certFpr = f[9] + break + } + } + cg, err := gpgKeyInfo(certFpr) + if err != nil { + t.Fatalf("gpgKeyInfo(cert-only): %v", err) + } + if _, err := gpgPins(certFpr, "sign", cg); err == nil || + !strings.Contains(err.Error(), "gpg --quick-add-key "+certFpr+" ed25519 sign") { + t.Errorf("a key with no signing subkey under scope sign: %v", err) + } + + // The real start: the proxy is bound, the keyring staged, the socket named. + pol := gpgIdentityPolicy() + pol.Identity.GPG.Fingerprint = fpr + cleanup, err := startGPGIdentity(pol, pol.Identity, false, false) + if err != nil { + t.Fatalf("startGPGIdentity: %v", err) + } + defer cleanup() + ring, ok := pol.Mounts[pol.Home+"/"+policy.GPGKeyringGuest] + if !ok || ring.Kind != policy.KindData || len(ring.Content) == 0 { + t.Fatalf("no staged keyring: %+v (ok %v)", ring, ok) + } + if bytes.Contains(ring.Content, []byte("PRIVATE KEY")) { + t.Error("the staged keyring holds private key text") + } + sock, ok := pol.Mounts[pol.Home+"/"+policy.GPGAgentSocketGuest] + if !ok { + t.Fatal("no socket mount") + } + if _, err := os.Stat(sock.Host); err != nil { + t.Errorf("the proxy socket %s was not bound: %v", sock.Host, err) + } +} diff --git a/internal/cli/identity.go b/internal/cli/identity.go index 1acc736f..e37ce83a 100644 --- a/internal/cli/identity.go +++ b/internal/cli/identity.go @@ -153,6 +153,19 @@ func startIdentity(pol *policy.Policy, verbose, dryRun bool) (cleanup func(), er } } + // The gpg half, gated on the BLOCK rather than a mode: a pinned gpg key + // has no mode — the block IS the proxy. A block that sets a fingerprint is + // a request for the agent, and Resolve has already refused a half block. + if id.GPG != (policy.IdentityGPG{}) { + gpgCleanup, gerr := startGPGIdentity(pol, id, verbose, dryRun) + if gerr != nil { + cleanup() + return nil, gerr + } + inner := cleanup + cleanup = func() { gpgCleanup(); inner() } + } + if err := stageGhConfig(pol, id, dryRun, ghToken); err != nil { cleanup() return nil, err diff --git a/internal/cli/report.go b/internal/cli/report.go index a05bcc29..e5f6f01b 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -130,7 +130,9 @@ type Report struct { // BrowserBridge is nil when the policy's login set lacks claude. BrowserBridge *reportBrowserBridge // Kube is nil when no identity.kubectl block is pinned. - Kube *reportKube + Kube *reportKube + // GPG is nil when no identity.gpg block is pinned. + GPG *reportGPG Topology reportTopology Containers *reportContainers // nil when no engine runs Environment []reportEnvVar @@ -592,6 +594,7 @@ func buildReport(env policy.Environ, p *policy.Policy, args []string, cfg config } rep.BrowserBridge = buildBrowserBridgeReport(env, p) rep.Kube = buildKubeReport(p) + rep.GPG = buildGPGReport(p) rep.BwrapExec = execResolution("bwrap") rep.Pasta = buildPastaReport(p) for _, name := range p.EnvNames() { diff --git a/internal/cli/testdata/gpg-decrypt.dryrun.txt b/internal/cli/testdata/gpg-decrypt.dryrun.txt new file mode 100644 index 00000000..654a1297 --- /dev/null +++ b/internal/cli/testdata/gpg-decrypt.dryrun.txt @@ -0,0 +1,3 @@ +GPG ~/.gnupg/S.gpg-agent is a filtering proxy to your host gpg-agent + key 6732361300BBFDAE7F22972CD4381E1CE2880244, the only key listed or usable + decrypt anything addressed to the key that the sandbox can read diff --git a/internal/cli/testdata/gpg-decrypt.filesystem.txt b/internal/cli/testdata/gpg-decrypt.filesystem.txt new file mode 100644 index 00000000..5a9df074 --- /dev/null +++ b/internal/cli/testdata/gpg-decrypt.filesystem.txt @@ -0,0 +1,48 @@ +FILESYSTEM (deny-by-default; every line is a grant, there are no deny rules) + link /bin -> usr/bin @sys + dev /dev (snug) + link /lib -> usr/lib @sys + link /lib64 -> usr/lib64 @sys + proc /proc (snug) + link /sbin -> usr/sbin @sys + tmpfs /tmp (max 1 GiB) (snug) + ro /usr @sys + data /etc/group (snug) + ro /etc/ld.so.cache @sys + ro /etc/ld.so.conf @sys + data /etc/nsswitch.conf (snug) + data /etc/passwd (snug) + data /etc/resolv.conf (snug) + tmpfs /home/u (max 1 GiB) @home + data /proc/config.gz (snug) + ← replaced with an EMPTY file — the host's copy is the + complete host kernel config — an exploit-selection oracle + data /proc/key-users (snug) + ← replaced with an EMPTY file — the host's copy is the host + user's keyring accounting + data /proc/keys (snug) + ← replaced with an EMPTY file — the host's copy is the host + user's live keyring (keyctl is already seccomp-denied) + ro /proc/sys (snug) + ← read-only: the write side is snug's own mount now, not a + capability check snug does not own. Reads are unchanged, + and namespace-scoped values are still this sandbox's + tmpfs /home/u/.cache (max 1 GiB) @home + tmpfs /home/u/.config (max 1 GiB) @home + tmpfs /home/u/.gnupg (max 1 GiB) identity:signer + tmpfs /home/u/.local (max 1 GiB) (snug anchor) + tmpfs /home/u/proj (max 1 GiB) (snug anchor) + rw /home/u/.gnupg/S.gpg-agent (from /run/user/1000/snug/run-N/gpg-agent.sock) identity:signer + data /home/u/.gnupg/pubring.kbx identity:signer + tmpfs /home/u/.local/share (max 1 GiB) @home + tmpfs /home/u/.local/state (max 1 GiB) @home + rw /home/u/proj/sub @target-rw + ro-/ everything else is a read-only skeleton (--remount-ro /) + ← (snug anchor) rows are snug's own empty tmpfs at an + ancestor of a grant. They hold nothing and grant nothing + — the payload could already write that path through the + tmpfs covering it — and they exist so it cannot rename + the path out from under a mount (issue #553). Each one is + a filesystem BOUNDARY: moving a granted directory across + one is a copy followed by a delete, and the copy lands in + ephemeral storage while the delete reaches your host. diff --git a/internal/cli/testdata/gpg.dryrun.txt b/internal/cli/testdata/gpg.dryrun.txt new file mode 100644 index 00000000..55cd346d --- /dev/null +++ b/internal/cli/testdata/gpg.dryrun.txt @@ -0,0 +1,4 @@ +GPG ~/.gnupg/S.gpg-agent is a filtering proxy to your host gpg-agent + key 6732361300BBFDAE7F22972CD4381E1CE2880244, the only key listed or usable + sign anything, as you, while the run lasts + decrypt anything addressed to the key that the sandbox can read diff --git a/internal/cli/testdata/gpg.filesystem.txt b/internal/cli/testdata/gpg.filesystem.txt new file mode 100644 index 00000000..3a985db9 --- /dev/null +++ b/internal/cli/testdata/gpg.filesystem.txt @@ -0,0 +1,49 @@ +FILESYSTEM (deny-by-default; every line is a grant, there are no deny rules) + link /bin -> usr/bin @sys + dev /dev (snug) + link /lib -> usr/lib @sys + link /lib64 -> usr/lib64 @sys + proc /proc (snug) + link /sbin -> usr/sbin @sys + tmpfs /tmp (max 1 GiB) (snug) + ro /usr @sys + data /etc/group (snug) + ro /etc/ld.so.cache @sys + ro /etc/ld.so.conf @sys + data /etc/nsswitch.conf (snug) + data /etc/passwd (snug) + data /etc/resolv.conf (snug) + tmpfs /home/u (max 1 GiB) @home + data /proc/config.gz (snug) + ← replaced with an EMPTY file — the host's copy is the + complete host kernel config — an exploit-selection oracle + data /proc/key-users (snug) + ← replaced with an EMPTY file — the host's copy is the host + user's keyring accounting + data /proc/keys (snug) + ← replaced with an EMPTY file — the host's copy is the host + user's live keyring (keyctl is already seccomp-denied) + ro /proc/sys (snug) + ← read-only: the write side is snug's own mount now, not a + capability check snug does not own. Reads are unchanged, + and namespace-scoped values are still this sandbox's + tmpfs /home/u/.cache (max 1 GiB) @home + tmpfs /home/u/.config (max 1 GiB) @home + data /home/u/.gitconfig identity:signer + tmpfs /home/u/.gnupg (max 1 GiB) identity:signer + tmpfs /home/u/.local (max 1 GiB) (snug anchor) + tmpfs /home/u/proj (max 1 GiB) (snug anchor) + rw /home/u/.gnupg/S.gpg-agent (from /run/user/1000/snug/run-N/gpg-agent.sock) identity:signer + data /home/u/.gnupg/pubring.kbx identity:signer + tmpfs /home/u/.local/share (max 1 GiB) @home + tmpfs /home/u/.local/state (max 1 GiB) @home + rw /home/u/proj/sub @target-rw + ro-/ everything else is a read-only skeleton (--remount-ro /) + ← (snug anchor) rows are snug's own empty tmpfs at an + ancestor of a grant. They hold nothing and grant nothing + — the payload could already write that path through the + tmpfs covering it — and they exist so it cannot rename + the path out from under a mount (issue #553). Each one is + a filesystem BOUNDARY: moving a granted directory across + one is a copy followed by a delete, and the copy lands in + ephemeral storage while the delete reaches your host. diff --git a/internal/cli/testdata/identity-gpg-decrypt.bwrap.txt b/internal/cli/testdata/identity-gpg-decrypt.bwrap.txt new file mode 100644 index 00000000..048aeda8 --- /dev/null +++ b/internal/cli/testdata/identity-gpg-decrypt.bwrap.txt @@ -0,0 +1,76 @@ +--unshare-user +--unshare-ipc +--unshare-pid +--unshare-uts +--unshare-cgroup-try +--unshare-net +--uid 1000 +--gid 1000 +--hostname snug +--die-with-parent +--cap-drop ALL +--perms 0755 +--dir /etc +--perms 0755 +--dir /home +--symlink usr/bin /bin +--dev /dev +--remount-ro /dev +--size 1073741824 +--tmpfs /dev/shm +--symlink usr/lib /lib +--symlink usr/lib64 /lib64 +--proc /proc +--symlink usr/sbin /sbin +--size 1073741824 +--tmpfs /tmp +--ro-bind-fd 18 /usr +--ro-bind-data 10 /etc/group +--ro-bind-fd 19 /etc/ld.so.cache +--ro-bind-fd 20 /etc/ld.so.conf +--ro-bind-data 11 /etc/nsswitch.conf +--ro-bind-data 12 /etc/passwd +--ro-bind-data 13 /etc/resolv.conf +--size 1073741824 +--tmpfs /home/u +--ro-bind-data 14 /proc/config.gz +--ro-bind-data 15 /proc/key-users +--ro-bind-data 16 /proc/keys +--ro-bind /proc/sys /proc/sys +--size 1073741824 +--tmpfs /home/u/.cache +--size 1073741824 +--tmpfs /home/u/.config +--perms 0700 +--size 1073741824 +--tmpfs /home/u/.gnupg +--size 1073741824 +--tmpfs /home/u/.local +--size 1073741824 +--tmpfs /home/u/proj +--bind-fd 21 /home/u/.gnupg/S.gpg-agent +--ro-bind-data 17 /home/u/.gnupg/pubring.kbx +--size 1073741824 +--tmpfs /home/u/.local/share +--size 1073741824 +--tmpfs /home/u/.local/state +--bind-fd 22 /home/u/proj/sub +--remount-ro / +--clearenv +--setenv GNUPGHOME /home/u/.gnupg +--setenv HOME /home/u +--setenv LOGNAME u +--setenv PATH /usr/bin:/bin:/usr/sbin:/sbin +--setenv PS1 🔒 snug:\w\$ +--setenv SHELL /usr/bin/bash +--setenv SNUG 1 +--setenv SNUG_PROFILES @home,@sys,@target-rw,signer +--setenv SNUG_TARGET /home/u/proj/sub +--setenv TMPDIR /tmp +--setenv USER u +--setenv XDG_CACHE_HOME /home/u/.cache +--setenv XDG_CONFIG_HOME /home/u/.config +--setenv XDG_DATA_HOME /home/u/.local/share +--setenv XDG_STATE_HOME /home/u/.local/state +--chdir /home/u/proj/sub +-- /bin/sh diff --git a/internal/cli/testdata/identity-gpg.bwrap.txt b/internal/cli/testdata/identity-gpg.bwrap.txt new file mode 100644 index 00000000..396d069e --- /dev/null +++ b/internal/cli/testdata/identity-gpg.bwrap.txt @@ -0,0 +1,78 @@ +--unshare-user +--unshare-ipc +--unshare-pid +--unshare-uts +--unshare-cgroup-try +--unshare-net +--uid 1000 +--gid 1000 +--hostname snug +--die-with-parent +--cap-drop ALL +--perms 0755 +--dir /etc +--perms 0755 +--dir /home +--symlink usr/bin /bin +--dev /dev +--remount-ro /dev +--size 1073741824 +--tmpfs /dev/shm +--symlink usr/lib /lib +--symlink usr/lib64 /lib64 +--proc /proc +--symlink usr/sbin /sbin +--size 1073741824 +--tmpfs /tmp +--ro-bind-fd 19 /usr +--ro-bind-data 10 /etc/group +--ro-bind-fd 20 /etc/ld.so.cache +--ro-bind-fd 21 /etc/ld.so.conf +--ro-bind-data 11 /etc/nsswitch.conf +--ro-bind-data 12 /etc/passwd +--ro-bind-data 13 /etc/resolv.conf +--size 1073741824 +--tmpfs /home/u +--ro-bind-data 14 /proc/config.gz +--ro-bind-data 15 /proc/key-users +--ro-bind-data 16 /proc/keys +--ro-bind /proc/sys /proc/sys +--size 1073741824 +--tmpfs /home/u/.cache +--size 1073741824 +--tmpfs /home/u/.config +--ro-bind-data 17 /home/u/.gitconfig +--perms 0700 +--size 1073741824 +--tmpfs /home/u/.gnupg +--size 1073741824 +--tmpfs /home/u/.local +--size 1073741824 +--tmpfs /home/u/proj +--bind-fd 22 /home/u/.gnupg/S.gpg-agent +--ro-bind-data 18 /home/u/.gnupg/pubring.kbx +--size 1073741824 +--tmpfs /home/u/.local/share +--size 1073741824 +--tmpfs /home/u/.local/state +--bind-fd 23 /home/u/proj/sub +--remount-ro / +--clearenv +--setenv GIT_CONFIG_GLOBAL /home/u/.gitconfig +--setenv GNUPGHOME /home/u/.gnupg +--setenv HOME /home/u +--setenv LOGNAME u +--setenv PATH /usr/bin:/bin:/usr/sbin:/sbin +--setenv PS1 🔒 snug:\w\$ +--setenv SHELL /usr/bin/bash +--setenv SNUG 1 +--setenv SNUG_PROFILES @home,@sys,@target-rw,signer +--setenv SNUG_TARGET /home/u/proj/sub +--setenv TMPDIR /tmp +--setenv USER u +--setenv XDG_CACHE_HOME /home/u/.cache +--setenv XDG_CONFIG_HOME /home/u/.config +--setenv XDG_DATA_HOME /home/u/.local/share +--setenv XDG_STATE_HOME /home/u/.local/state +--chdir /home/u/proj/sub +-- /bin/sh diff --git a/internal/cli/testdata/show-identity-gpg.capabilities.txt b/internal/cli/testdata/show-identity-gpg.capabilities.txt new file mode 100644 index 00000000..a3d9beff --- /dev/null +++ b/internal/cli/testdata/show-identity-gpg.capabilities.txt @@ -0,0 +1,11 @@ + identity gpg key 6732361300BBFDAE7F22972CD4381E1CE2880244 (sign) + THE SANDBOX CAN SIGN AS YOU with this OpenPGP key — + commits, tags, release artifacts, mail — for as long as the + run lasts. snug generates commit.gpgsign = true, so commits + inside are signed by default. No private key enters the + sandbox and your other keys cannot be listed or used. + gpg key 6732361300BBFDAE7F22972CD4381E1CE2880244 (decrypt) + THE SANDBOX CAN DECRYPT ANYTHING ADDRESSED TO THIS KEY that + it can read, for as long as the run lasts. The proxy sees an + opaque ciphertext and cannot tell which message it is + decrypting. diff --git a/internal/gpgproxy/escape_test.go b/internal/gpgproxy/escape_test.go new file mode 100644 index 00000000..a472fd6e --- /dev/null +++ b/internal/gpgproxy/escape_test.go @@ -0,0 +1,184 @@ +package gpgproxy + +import ( + "strings" + "testing" +) + +// ── the escaping helpers ──────────────────────────────────────────────────── +// +// This file exists because of a live-found bug that no amount of reading the +// spec would have caught: the pinned primary grip contains the byte 0x0A, and +// an Assuan D line is newline-terminated. Writing that grip raw truncated the +// payload, and gpg reported `problem with fast path key listing: Invalid IPC +// response - ignored` and then failed to sign at all. + +// TestAssuanEscapeIsMinimal: exactly %, LF and CR are escaped, and nothing else. +// Escaping more would still parse but would differ byte-for-byte from what the +// agent writes, and relayed D lines are copied verbatim, so the two must agree. +func TestAssuanEscapeIsMinimal(t *testing.T) { + cases := []struct{ in, want string }{ + {"plain", "plain"}, + {"a%0A", "a%250A"}, // a literal %0A becomes %250A + {"a\nb", "a%0Ab"}, // LF + {"a\rb", "a%0Db"}, // CR + {"a\x00b", "a\x00b"}, // NUL is NOT escaped (it does not break framing) + {"a\x0bb", "a\x0bb"}, // vertical tab is NOT escaped + {"a\x7fb", "a\x7fb"}, // DEL is NOT escaped + {"a\r\nb", "a%0D%0Ab"}, // both, in order + {"100%", "100%25"}, // trailing % + {"", ""}, // empty + } + for _, c := range cases { + if got := assuanEscape([]byte(c.in)); got != c.want { + t.Errorf("assuanEscape(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestAssuanUnescapeReversesEscape is the round-trip that stops the pair from +// drifting apart. The escape alphabet is chosen so the round trip is exact for +// every byte: %25 comes back as %, %0A as LF, %0D as CR, and any other byte is +// literal and unchanged. +func TestAssuanUnescapeReversesEscape(t *testing.T) { + // Every byte value, as a payload. The round trip must be the identity. + all := make([]byte, 256) + for i := range all { + all[i] = byte(i) + } + if got := assuanUnescape(assuanEscape(all)); string(got) != string(all) { + t.Fatalf("round trip of all 256 bytes changed the payload:\n got %q\nwant %q", got, all) + } + for _, s := range []string{"", "plain", "\n", "\r", "%", "%0A", "%25", "\x00\xff"} { + if got := assuanUnescape(assuanEscape([]byte(s))); string(got) != s { + t.Errorf("round trip of %q = %q", s, got) + } + } +} + +// TestAssuanUnescapeLeavesUnknownEscapesAlone: the payload may be an opaque +// ciphertext, and inventing an error for a sequence the proxy does not recognise +// would refuse something gpg would have accepted. +func TestAssuanUnescapeLeavesUnknownEscapesAlone(t *testing.T) { + cases := []struct{ in, want string }{ + {"%ZZ", "%ZZ"}, // not hex + {"%0", "%0"}, // truncated + {"%", "%"}, // a lone percent + {"%0A", "\n"}, // known + {"%0a", "\n"}, // lowercase hex is accepted + {"a%0Ab", "a\nb"}, + {"100%25", "100%"}, + } + for _, c := range cases { + if got := string(assuanUnescape(c.in)); got != c.want { + t.Errorf("assuanUnescape(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestGripWithNewlineByteSurvivesHaveKeyList is THE regression test for the live +// bug. The measurement key's primary grip is 2761D2BF75F0095F0FEA 0A 9216F4114A3B0C20BA +// — the ninth byte is 0x0A. If HAVEKEY --list writes it raw, the D line ends +// early and the client reads a truncated payload. It must come back escaped +// (%0A) and unescape to exactly the two grips. +func TestGripWithNewlineByteSurvivesHaveKeyList(t *testing.T) { + const withNewline = "2761D2BF75F0095F0FEA0A9216F4114A3B0C20BA" + raw, err := parseGrip("f", withNewline) + if err != nil { + t.Fatal(err) + } + if raw[10] != 0x0A { + t.Fatalf("the fixture grip no longer contains a 0x0A byte at index 10 (got %#x); "+ + "this test would pass vacuously", raw[10]) + } + + a := newFakeAgent(t, withNewline, gripSubkey) + _, sock, _ := newTestProxy(t, a, []PinnedGrip{ + {Field: "identity.gpg.fingerprint", Grip: withNewline, Scope: ScopeSign}, + {Field: "identity.gpg.fingerprint", Grip: gripSubkey, Scope: ScopeDecrypt}, + }) + c := dial(t, sock) + reply := c.send("HAVEKEY --list=1000") + + // The reply must be exactly one D line plus OK. A raw 0x0A would have split + // it into two lines, which is what the client saw as `Invalid IPC response`. + if len(reply) != 2 { + t.Fatalf("HAVEKEY --list reply has %d lines, want 2 (one D, one OK): %q", len(reply), reply) + } + payload, ok := strings.CutPrefix(reply[0], "D ") + if !ok { + t.Fatalf("first reply line is not a D line: %q", reply[0]) + } + if !strings.Contains(payload, "%0A") { + t.Errorf("the 0x0A byte in the grip was not escaped to %%0A: %q", payload) + } + if strings.ContainsRune(payload, '\n') { + t.Errorf("the D payload contains a raw newline: %q", payload) + } + + // And it must decode back to exactly the two grips, in order. + decoded := assuanUnescape(payload) + wantPrimary, _ := parseGrip("f", withNewline) + wantSubkey, _ := parseGrip("f", gripSubkey) + want := append(append([]byte(nil), wantPrimary[:]...), wantSubkey[:]...) + if string(decoded) != string(want) { + t.Fatalf("decoded payload\n got %x\nwant %x", decoded, want) + } +} + +// TestForwardedDLineIsNotReEscaped: the proxy is a byte pipe for relayed lines. +// The measurement that settled it: on a real decrypt the client sent the +// ciphertext with no escapes at all, and the agent accepted it. If the proxy +// re-escaped a relayed D line it would corrupt the payload; if it unescaped one +// the agent sent, it would corrupt the signature or plaintext. +func TestForwardedDLineIsNotReEscaped(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + + // The fake agent's signature marker is ASCII; PKSIGN's D line must arrive + // byte-for-byte as the agent wrote it. + if got := terminalOf(c.send("RESET")); !strings.HasPrefix(got, "OK") { + t.Fatalf("RESET: %q", got) + } + c.send("SIGKEY " + gripPrimary) + reply := c.send("PKSIGN") + joined := strings.Join(reply, "\n") + if !strings.Contains(joined, "(7:sig-val(5:eddsa") { + t.Fatalf("the signature D line was not forwarded verbatim: %v", reply) + } + if strings.Contains(joined, "%28") || strings.Contains(joined, "%29") { + t.Errorf("a relayed D line was re-escaped: %v", reply) + } +} + +// TestInquireAnswerIsForwardedVerbatim: the client's ciphertext must reach the +// agent unchanged, escapes and all. gpg escapes only when it must, so a payload +// that arrives with a %0A must leave with it. +func TestInquireAnswerIsForwardedVerbatim(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + c.send("RESET") + c.send("SETKEY " + gripSubkey) + + const ciphertext = "(7:enc-val(4:ecdh(1:s4:%0Aab)(1:e4:efgh)))(7:trailing)" + if err := writeLine(c.w, "PKDECRYPT"); err != nil { + t.Fatal(err) + } + if err := c.w.Flush(); err != nil { + t.Fatal(err) + } + c.answerInquire(ciphertext) + + got := a.sawDecryptInquire() + if len(got) != 2 { + t.Fatalf("the agent saw %d inquire lines, want 2 (a D and an END): %q", len(got), got) + } + if got[0] != "D "+ciphertext { + t.Fatalf("the ciphertext was altered in flight:\n got %q\nwant %q", got[0], "D "+ciphertext) + } + if got[1] != "END" { + t.Fatalf("the terminating line was %q, want END", got[1]) + } +} diff --git a/internal/gpgproxy/proxy.go b/internal/gpgproxy/proxy.go new file mode 100644 index 00000000..f696eb3c --- /dev/null +++ b/internal/gpgproxy/proxy.go @@ -0,0 +1,1472 @@ +// Package gpgproxy is a filtering gpg-agent proxy: the sandbox sees only the +// keygrips the profile's fingerprint resolves to, and cannot enumerate, export, +// delete or otherwise reach any other key in your agent. +// +// Why a proxy rather than binding $GNUPGHOME/S.gpg-agent straight through: +// gpg-agent's Assuan protocol has verbs beyond "sign" and "decrypt". A raw +// passthrough hands the sandbox (verbs from gpg-agent 2.5.24's HELP): +// +// - HAVEKEY --list=1000, KEYINFO and KEYINFO --list: every keygrip in your +// agent, whether or not the profile named the key. +// - EXPORT_KEY (with KEYWRAP_KEY --export and a preceding KEYINFO): the +// PRIVATE KEY MATERIAL, transferred over the socket. `gpg --export-secret-keys` +// drives exactly this, and it is the verb that makes a raw socket a +// key-exfiltration primitive rather than a signing oracle. +// - DELETE_KEY, IMPORT_KEY, GENKEY, PASSWD, CLEAR_PASSPHRASE: mutation of, +// and denial of service against, the human's agent. +// - OPTION, SETKEYDESC and PRESET_PASSPHRASE: a way to choose where the +// human's pinentry appears and what it says, so the agent asks YOU for +// something the sandbox wrote. +// +// Every verb not named in session.command is refused. Pinning to one +// fingerprint's SUBKEY grips bounds the blast radius to one identity, minus +// certification. +// +// WHAT THIS CANNOT DO, and it is inherent rather than a gap to close later: it +// cannot restrict WHAT is signed or decrypted. A signature oracle for a key is +// authority to use that key for anything it can reach. And for decryption the +// proxy is structurally blind in a second way too: PKDECRYPT takes the +// ciphertext as a server-driven INQUIRE, so the proxy relays a blob it cannot +// interpret, and it cannot tell you what was decrypted. Pinning bounds the +// identity, not the actions. Every agent forwarder in existence has this +// property. What the proxy CAN hold is the verb: a key selected for decryption +// never reaches PKSIGN, and the only question the agent may ask the sandbox is +// INQUIRE CIPHERTEXT. +// +// THIS PACKAGE HOLDS KEYGRIPS, NEVER KEYS. It reads no key file, executes no +// external program, and touches no filesystem path. The fingerprint -> keygrip +// resolution happens in internal/cli, which is the layer that already shells +// out; the proxy is handed grips the same way sshproxy.New is handed blobs. +// That is what keeps "what a profile pins" from being a concept this package +// has to learn — see PinnedGrip. +// +// The protocol is implemented at the wire level rather than through a library +// because the filter IS the security control, and it should be readable as +// bytes-in, bytes-out with no framework in between. Assuan is line-oriented: +// commands and replies are \n-terminated, replies are D (data), S (status) and +// # (comment) lines followed by OK or ERR . +package gpgproxy + +import ( + "bufio" + "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "errors" + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/gomoni/snug/internal/unixserve" +) + +// maxLine bounds one Assuan line read from the sandbox. A command line names a +// keygrip or an option and is small; 64 KiB is far above anything a client +// legitimately sends. An unbounded ReadString from the sandbox is a memory +// exhaustion primitive pointed at snug itself. +const maxLine = 64 * 1024 + +// maxReply bounds how many bytes the proxy will copy from the agent for one +// command before it gives up. A PKDECRYPT reply and an EXPORT_KEY reply are the +// large ones; the former is bounded by the message it decrypts, the latter is +// refused outright. 1 MiB is generous and finite. +const maxReply = 1 << 20 + +// Scope is what a profile grants for one pinned key. It is a BIT SET rather +// than a string so the caller's parse is the only place that has to know the +// spelling, and so a union across profiles is an OR. +// +// There is deliberately no "encrypt" bit. Measured: encrypting to the pinned +// key produced an EMPTY agent log — gpg encrypts from the public key alone, the +// same way `gpg --export` needs no agent. A value that is accepted and grants +// nothing is the disease CLAUDE.md names ("podman = \"off\" is refused for +// precisely this"), so encryption is documented as arriving with either scope +// and is not spelled. +// +// There is deliberately no "auth" bit either: gpg-agent's ssh-emulation side is +// a different protocol on the same socket that snug has not measured, and a +// value whose verbs have not been read into this file would be a scope that +// cannot be enforced. It is left unbuilt rather than guessed, per +// .claude/agents/host-bridge.md. +type Scope uint8 + +const ( + // ScopeSign permits SIGKEY , SETHASH and a PKSIGN that follows it. + ScopeSign Scope = 1 << iota + // ScopeDecrypt permits SETKEY and a PKDECRYPT that follows it. It + // deliberately does NOT permit SIGKEY, nor a PKSIGN after SETKEY: a + // decryption-only grant must not double as a signing oracle. + ScopeDecrypt +) + +// String is for audit and error text only. +func (s Scope) String() string { + switch s { + case 0: + return "nothing" + case ScopeSign: + return "sign" + case ScopeDecrypt: + return "decrypt" + case ScopeSign | ScopeDecrypt: + return "sign+decrypt" + default: + return fmt.Sprintf("scope(0x%x)", uint8(s)) + } +} + +// PinnedGrip is one keygrip the sandbox may use, the scope the profile granted +// it, and the profile field that named it. Field is a LABEL for error and audit +// text only — this package does not know the identity schema, and must not +// learn it. The grip itself is a 40-hex-character string, the same one +// `gpg --with-keygrip` prints on a `grp:` line. +// +// The SCOPE travels with the grip rather than with the Proxy because one +// profile can pin a primary key for signing and its encryption subkey for +// decryption, and the two grips then carry different scopes. internal/cli +// derives both grips from a single fingerprint and sets Scope here; this +// package never sees the fingerprint. +type PinnedGrip struct { + Field string // "identity.gpg.fingerprint" + Grip string // 40 hexadecimal characters + Scope Scope // what the sandbox may do with THIS grip +} + +// grip is a PinnedGrip with the grip decoded to 20 bytes for constant-time +// comparison. +type grip struct { + field string + grip string + raw [20]byte + sign bool + decrypt bool +} + +type Proxy struct { + // pinned is the SET of keygrips the sandbox may use. Membership is the + // WHOLE filter; see the package comment for why it cannot also be a filter + // on purpose. It is never empty — New refuses an empty set. + pinned []grip + + upstream string // the host's real gpg-agent socket + audit func(string) + + srv *unixserve.Server +} + +// New prepares a proxy pinned to the given keygrips. No key material is read: +// the fingerprints were already resolved to grips by the caller, and the only +// thing this function does on the host is ask the agent, ON ONE CONNECTION +// before the listener is bound, whether it holds each grip and — for a grip +// carrying ScopeSign — whether it will actually sign with it. New refuses when +// either answer is no. See probeUpstream and probeSign for why neither check is +// optional and what they do not promise. +// +// Order is load-bearing: the grip set is checked before the upstream is, so a +// profile error beats a host-state error; and the probe runs before the listener +// is bound, so a refusal leaves no socket behind. +// +// The startup probe's pinentry, if the key is locked, is pointed at the +// terminal and display in os.Getenv; NewEnv takes them from a caller instead. +func New(grips []PinnedGrip, upstream, socketPath string, audit func(string)) (*Proxy, error) { + return NewEnv(grips, upstream, socketPath, audit, os.Getenv) +} + +// NewEnv is New with the environment the startup probe's pinentry options are +// read from passed in. +func NewEnv(grips []PinnedGrip, upstream, socketPath string, audit func(string), + getenv func(string) string) (*Proxy, error) { + if len(grips) == 0 { + return nil, errors.New("gpg-agent proxy: no keygrip to pin. A proxy with an empty pin set " + + "advertises nothing and refuses every signature, which is a sandbox that looks " + + "configured and is not") + } + var pins []grip + for _, g := range grips { + if g.Scope == 0 { + return nil, fmt.Errorf("%s pins keygrip %s with no scope: it would be "+ + "advertised by HAVEKEY and usable for nothing.\n\n"+ + " A grip with an empty scope is a grant that grants nothing, which is the\n"+ + " shape snug refuses rather than accepts. Set identity.gpg.scope in the\n"+ + " profile (\"sign\", \"decrypt\", or both).", g.Field, truncate(g.Grip, 40)) + } + raw, err := parseGrip(g.Field, g.Grip) + if err != nil { + return nil, err + } + if containsGrip(pins, raw) { + continue // the same grip named twice advertises once + } + pins = append(pins, grip{ + field: g.Field, + grip: strings.ToLower(g.Grip), + raw: raw, + sign: g.Scope&ScopeSign != 0, + decrypt: g.Scope&ScopeDecrypt != 0, + }) + } + if upstream == "" { + return nil, fmt.Errorf("no gpg-agent is running on the host (GNUPGHOME is unset, or " + + "gpgconf reports no agent socket), so there is nothing to proxy.\n" + + " Start one with any gpg command, e.g. gpg --list-keys") + } + if err := probeUpstream(upstream, pins, getenv); err != nil { + return nil, err + } + + ln, err := net.Listen("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("gpg-agent proxy socket: %w", err) + } + // The socket lives in a private per-run directory, but belt and braces: + // anything that can connect can ask for a signature or a decryption. + if err := os.Chmod(socketPath, 0o600); err != nil { + ln.Close() + return nil, fmt.Errorf("gpg-agent proxy: could not restrict %s to this user (%w); "+ + "anything else on this host that can connect to it could ask for a signature", + socketPath, err) + } + + if audit == nil { + audit = func(string) {} + } + return &Proxy{pinned: pins, upstream: upstream, srv: unixserve.New(ln), audit: audit}, nil +} + +func (p *Proxy) Serve() { p.srv.Serve(p.handle) } + +// Close stops the proxy and ends every live sandbox connection. +func (p *Proxy) Close() { p.srv.Close() } + +// handle serves one client connection. +// +// Every command is either answered LOCALLY (enumeration, GETINFO), REFUSED +// (mutation, export, anything unrecognised), or FORWARDED to the host agent +// after the grip and scope are checked. +// +// STATE IS PER CLIENT CONNECTION, AND SO IS THE UPSTREAM CONNECTION. Assuan is +// a stateful request/response stream: SIGKEY sets the key on the connection and +// PKSIGN uses it, so the two MUST reach the same agent connection. A fresh +// upstream connection per command (which sshproxy can afford, because its +// SIGN_REQUEST is self-contained) would lose the SIGKEY and make every PKSIGN +// fail with `No secret key`. So one upstream connection is opened lazily for +// the life of one client connection and reused; two clients never share one. +func (p *Proxy) handle(sandbox net.Conn) { + s := &session{proxy: p, r: bufio.NewReaderSize(sandbox, 4096), w: bufio.NewWriter(sandbox)} + defer s.close() + + // Greeting phrase. A client that reads the greeting is told the agent is + // here; the wording is not gpg-agent's own verbatim, because the proxy is + // not claiming to be the host agent. + if err := writeLine(s.w, "OK Pleased to meet you, snug gpg-agent proxy"); err != nil { + return + } + if err := s.w.Flush(); err != nil { + return + } + + for { + line, err := readLine(s.r) + if err != nil { + return + } + if line == "" { + continue + } + if !s.command(line) || s.ended { + _ = s.w.Flush() + return + } + if err := s.w.Flush(); err != nil { + return + } + } +} + +// session is one sandbox connection and its lazily-opened upstream. +type session struct { + proxy *Proxy + r *bufio.Reader + w *bufio.Writer + + up net.Conn + upR *bufio.Reader + upW *bufio.Writer + upErr error + upOpen bool + + // ended is set when the session cannot continue in step with the agent — + // a reply cut off at maxReply, an INQUIRE relay that failed — so the + // connection is closed rather than left to read the rest of an old reply as + // the answer to the next command. + ended bool + + // selected is the scope of the verb that last chose this connection's key: + // ScopeSign after an admitted SIGKEY, ScopeDecrypt after an admitted SETKEY, + // zero after RESET or any refused selection. PKSIGN needs ScopeSign and + // PKDECRYPT needs ScopeDecrypt. + // + // The grip check at SIGKEY/SETKEY is not enough on its own, because the + // agent keeps ONE selected key for both verbs. Measured on gpg-agent 2.5.24 + // through this proxy under a decrypt-only profile: `SETKEY `, + // `SETHASH`, `PKSIGN` returned a sig-val — a decryption grant was a signing + // oracle. + selected Scope +} + +func (s *session) close() { + if s.up != nil { + // BYE tells the agent this connection is done, so it does not wait on + // a half-open socket. Best-effort: a dead agent needs no courtesy. + _ = writeLine(s.upW, "BYE") + _ = s.upW.Flush() + s.up.Close() + } +} + +// upstream returns the shared upstream connection, opening it on first use. +// The error is sticky so a dead agent is reported once per connection rather +// than re-dialled per command. +func (s *session) upstream() (*bufio.Reader, *bufio.Writer, error) { + if s.upOpen { + return s.upR, s.upW, s.upErr + } + s.upOpen = true + // The dial, greeting and OPTION exchange are bounded; the commands after + // them are not, because PKSIGN and PKDECRYPT may wait on a pinentry a + // human answers. + conn, err := net.DialTimeout("unix", s.proxy.upstream, probeTimeout) + if err != nil { + s.upErr = err + return nil, nil, err + } + if err := conn.SetDeadline(time.Now().Add(probeTimeout)); err != nil { + conn.Close() + s.upErr = err + return nil, nil, err + } + s.up = conn + s.upR = bufio.NewReaderSize(conn, 4096) + s.upW = bufio.NewWriter(conn) + // Consume the agent's greeting. + for { + line, err := readLine(s.upR) + if err != nil { + s.upErr = err + return nil, nil, err + } + if isTerminal(line) { + break + } + } + // Propagate TTY/display context to the agent so pinentry behaves like gpg + // does. Without this, each new Assuan connection can spawn a different + // pinentry program than the one that was primed by gpg-connect-agent. + // No OPTION is sent here. A pinentry for a sandbox command takes the + // agent's own startup environment, never a tty or display the sandbox or + // snug's terminal names, because the sandbox shares that terminal and + // could read what the human types into it. + if err := conn.SetDeadline(time.Time{}); err != nil { + s.upErr = err + return nil, nil, err + } + return s.upR, s.upW, nil +} + +// command dispatches one client line. It returns false to end the connection. +func (s *session) command(line string) bool { + p := s.proxy + name, args := splitCommand(line) + + switch strings.ToUpper(name) { + // ── answered locally ──────────────────────────────────────────────── + case "HAVEKEY": + p.handleHaveKey(s.w, args) + case "KEYINFO": + p.handleKeyInfo(s.w, args) + case "GETINFO": + p.handleGetInfo(s, args) + + // ── forwarded (grip + scope checked) ──────────────────────────────── + case "RESET": + s.selected = 0 + s.forward(line) + case "OPTION": + p.handleOption(s.w, args) + case "SETKEYDESC": + // The prompt text is the proxy's, written after an admitted + // SIGKEY/SETKEY. Measured through this proxy: a sandbox-authored + // SETKEYDESC ("system update needs your password") reached the host + // pinentry verbatim. + writeLine(s.w, "OK") + case "SETHASH": + s.forward(line) + + case "SIGKEY": + s.handleSetKey(line, args, ScopeSign) + case "SETKEY": + s.handleSetKey(line, args, ScopeDecrypt) + case "PKSIGN": + if s.selected != ScopeSign { + p.refuse(s.w, "refused: pksign without a key selected by sigkey") + break + } + s.forward("PKSIGN") + case "PKDECRYPT": + if s.selected != ScopeDecrypt { + p.refuse(s.w, "refused: pkdecrypt without a key selected by setkey") + break + } + s.handlePKDECRYPT(line, args) + + case "BYE": + _ = writeLine(s.w, "OK closing connection") + return false + + // ── refused ───────────────────────────────────────────────────────── + case "KEYWRAP_KEY", "EXPORT_KEY", "IMPORT_KEY": + // EXPORT_KEY is the verb that transfers PRIVATE KEY MATERIAL over the + // socket; `gpg --export-secret-keys` drives it. This refusal is the + // single most important line in the file. + p.refuse(s.w, "refused: sandbox tried to export private key material from the host agent") + case "DELETE_KEY": + p.refuse(s.w, "refused: sandbox tried to delete a key from the host agent") + case "PASSWD", "PRESET_PASSPHRASE", "CLEAR_PASSPHRASE", "GET_PASSPHRASE": + p.refuse(s.w, "refused: sandbox tried to set or read a passphrase on the host agent") + case "GENKEY", "KEYTOCARD", "KEYTOTPM", "KEYATTR", "PUT_SECRET", "GET_SECRET": + p.refuse(s.w, "refused: sandbox tried to create or manipulate keys in the host agent") + case "UPDATESTARTUPTTY", "KILLAGENT", "RELOADAGENT", "GETEVENTCOUNTER": + p.refuse(s.w, "refused: sandbox tried to reconfigure or control the host agent") + + case "SCD": + // MEASURED, and it cost a live test to learn: `SCD SERIALNO` is part of + // gpg's NORMAL path, not an attack. Every `gpg -d` sends it before + // SETKEY, and gpg's key listing sends it too; with no card, gpg-agent + // 2.4.4 and 2.5.24 answer `ERR 100696144 No such device ` + // (measured; scdaemon installed). Refusing it wholesale broke + // signing AND decryption with `problem with fast path key listing: + // Invalid IPC response`. + // + // So only the read-only query is forwarded. Everything else SCD can do + // — LEARN, APDU, the write verbs — is refused, because those drive card + // operation and are not something a sandbox needs to reach. + if strings.EqualFold(strings.TrimSpace(args), "SERIALNO") { + s.forward(line) + } else { + p.refuse(s.w, "refused: sandbox tried to drive the smartcard layer (SCD "+ + truncate(strings.ToUpper(strings.TrimSpace(args)), 20)+")") + } + + default: + p.audit(fmt.Sprintf("refused: unknown gpg-agent command %q", truncate(name, 40))) + writeLine(s.w, errForbidden) + } + return true +} + +// errForbidden is the refusal the host agent itself gives on its restricted +// socket (agent-extra-socket), measured against gpg-agent 2.5.24 for +// EXPORT_KEY, DELETE_KEY and GETINFO socket_name: GPG_ERR_FORBIDDEN (251) from +// source GPG_ERR_SOURCE_GPGAGENT (4 << 24). Code 0 is GPG_ERR_NO_ERROR, so an +// `ERR 0` line reads to libgpg-error as a failure that is also a success. +const errForbidden = "ERR 67109115 Forbidden " + +// refuse audits and answers the agent's own Forbidden. +func (p *Proxy) refuse(w *bufio.Writer, what string) { + p.audit(what) + writeLine(w, errForbidden) +} + +// ── the local answers ─────────────────────────────────────────────────────── + +// handleOption answers OPTION locally and forwards none. +// +// OPTION sets the environment the host agent gives its pinentry, and every +// value of it measured through the proxy was a hole: `ttyname=` +// made pinentry-curses open that path on the HOST and overwrite it with the +// prompt (a 9-byte file became 17792 bytes), `pinentry-mode=loopback` turned +// the passphrase prompt into an INQUIRE the sandbox answers (unlimited +// guessing), and `allow-pinentry-notify` makes the agent report the host +// pinentry's pid and tty. The names gpg sends on every connection are +// answered OK so gpg proceeds; pinentry-mode and anything unlisted is +// refused. +func (p *Proxy) handleOption(w *bufio.Writer, args string) { + name, _, _ := strings.Cut(strings.TrimSpace(args), "=") + name, _, _ = strings.Cut(name, " ") + switch strings.ToLower(strings.TrimSpace(name)) { + case "ttyname", "ttytype", "display", "xauthority", "putenv", "lc-ctype", + "lc-messages", "allow-pinentry-notify", "agent-awareness": + writeLine(w, "OK") + default: + p.refuse(w, fmt.Sprintf("refused: OPTION %q", truncate(name, 40))) + } +} + +// handleHaveKey answers the two HAVEKEY forms the proxy must not forward. +// +// Measured against gpg-agent 2.4.4 and 2.5.24: +// +// HAVEKEY --list=N -> D / OK +// HAVEKEY [...] -> OK when ANY is held, ERR when none is +// +// (`HAVEKEY 0000…` answered OK on both.) The proxy answers the same +// question about its pin set. That matters with subkey-only pinning: gpg names +// the primary and its subkeys on one line, and the primary is never pinned. +// +// Measured through this proxy, and it cost a live test to learn: the multi-grip +// form gpg sends is `HAVEKEY ` — BOTH grips on ONE line, and +// this is gpg's "fast path key listing", the shortcut it takes before any +// signing or decryption. The real agent answers OK for it, and so must the +// proxy: refusing (or narrowing it to sign-capable grips) makes gpg report +// `problem with fast path key listing: Invalid IPC response - ignored` and the +// operation fails with no cause. HAVEKEY asks "does this key EXIST here", not +// "may I use it for the operation I have not sent yet" — the scope is enforced +// at SIGKEY/SETKEY, which is the verb that names the capability. +// +// The answer comes from OUR pin set, not the agent's: a grip the agent holds +// but the profile did not pin must read as absent, exactly as sshproxy's +// identitiesAnswer never mentions an unpinned key. +func (p *Proxy) handleHaveKey(w *bufio.Writer, args string) { + if rest, ok := strings.CutPrefix(args, "--list="); ok { + // The client asks for N bytes worth; we answer with the pinned grips + // that FIT, in declaration order. A grip is 20 bytes on the wire. + raw := p.pinnedRaw() + want := len(raw) * 20 + if n, err := atoiSafe(rest); err == nil && n < want { + // Truncate to a whole number of grips, rounding DOWN: a partial + // grip would be unparsable by the client. + whole := n / 20 + if whole < len(raw) { + raw = raw[:whole] + } + } + if len(raw) > 0 { + // MEASURED, and it cost a live test: the D payload is + // PERCENT-ESCAPED. gpg's fast-path key listing failed with + // `Invalid IPC response` because the pinned primary grip contains + // a 0x0A byte, which the agent writes as the three characters + // "%0A" and this proxy was writing raw. Assuan escapes %, \n and + // \r in D lines; a grip is 20 arbitrary bytes, so roughly one grip + // in ninety needs it. + writeLine(w, "D "+assuanEscape(concatGrips(raw))) + } + writeLine(w, "OK") + return + } + // HAVEKEY [ ...]: OK when any named grip is pinned here. + for _, name := range strings.Fields(args) { + raw, err := parseGrip("HAVEKEY", name) + if err != nil { + continue + } + if containsGrip(p.pinned, raw) { + writeLine(w, "OK") + return + } + } + // The agent's own text for a grip it does not hold, so a client sees the + // same thing it would see from a real agent. + writeLine(w, "ERR 67108881 No secret key") +} + +// handleKeyInfo answers KEYINFO and KEYINFO --list locally, for the +// pinned grips only. Measured reply shape: +// +// S KEYINFO D - - - C - - - / OK +func (p *Proxy) handleKeyInfo(w *bufio.Writer, args string) { + if strings.TrimSpace(args) == "--list" { + for _, g := range p.pinned { + writeLine(w, "S KEYINFO "+g.grip+" D - - - C - - -") + } + writeLine(w, "OK") + return + } + for _, name := range strings.Fields(args) { + raw, err := parseGrip("KEYINFO", name) + if err != nil { + writeLine(w, "ERR 67108891 Not found") + return + } + g, ok := gripOf(p.pinned, raw) + if !ok { + writeLine(w, "ERR 67108891 Not found") + return + } + writeLine(w, "S KEYINFO "+g.grip+" D - - - C - - -") + } + writeLine(w, "OK") +} + +// handleGetInfo forwards the GETINFO forms a client needs to talk to the agent +// and refuses the rest. +// +// Measured against gpg-agent 2.5.24, the refused forms answer with HOST state: +// `pid` and `connections` name the host process, `socket_name` and +// `ssh_socket_name` the host paths (an invitation to try the ssh socket +// directly), and `std_session_env`, `std_startup_env` and `getenv NAME` the +// agent's environment — GPG_TTY, DISPLAY, XAUTHORITY and anything else it was +// started with. The agent's own restricted socket forbids the same set. +// +// `version` is forwarded, not answered locally. gpg compares it against its own +// version and picks its protocol from the answer, so a fixed string is a lie +// the client acts on: answering 2.4.8 to gpg 2.5 printed "server 'gpg-agent' is +// older than us" on every call. The version names no path, process or secret. +// `cmd_has_option` is a question about the protocol the agent speaks, so the +// agent is the authority on it too. +func (p *Proxy) handleGetInfo(s *session, args string) { + what := strings.TrimSpace(args) + name, rest := splitCommand(what) + switch name { + case "cmd_has_option": + // The one form that takes arguments: a command name and an option. + s.forward("GETINFO " + what) + case "version", "restricted", "s2k_count", "s2k_time", "s2k_count_cal", + "scd_running", "jent_active": + // No arguments, so none are passed on: a trailing word would reach a + // future agent that gives it a meaning, unreviewed. + if strings.TrimSpace(rest) != "" { + p.refuse(s.w, fmt.Sprintf("refused: GETINFO %s takes no argument", name)) + return + } + s.forward("GETINFO " + name) + default: + p.refuse(s.w, fmt.Sprintf("refused: GETINFO %q describes the host agent", truncate(name, 40))) + } +} + +// ── the checked forwards ──────────────────────────────────────────────────── + +// handleSetKey accepts SIGKEY under ScopeSign, or SETKEY +// under ScopeDecrypt, iff the grip is in the pin set AND carries the scope the +// verb needs, then forwards it upstream on the session's connection. +// +// Both verbs are handled here because they differ in exactly three ways: the +// verb name, the grip they may name, and the scope that admits them. Measured: +// signing uses `SIGKEY `, decryption uses +// `SETKEY `. A decryption-only grant must not accept +// SIGKEY, and a signing-only grant must not accept SETKEY. +// +// The scope check is the whole reason SETKEY cannot simply be forwarded: gpg +// answers `OK` to SETKEY for ANY grip, including one it does not hold +// (measured: `SETKEY 0000…` → `OK`), and only fails later at PKDECRYPT with +// `No secret key`. So the guard must be here, at the verb that names the grip, +// or a decrypt scope would be enforceable only after the INQUIRE is in flight. +// +// SETKEY may also carry `--another`: measured with gpg 2.5.24, a post-quantum +// (composite Kyber) subkey has two grips and decryption sends `SETKEY ` +// then `SETKEY --another `. The second grip is checked exactly like the +// first; the option changes which slot it fills, not what is required of it. +// +// `--another` ADDS a grip to the key a plain SETKEY selected, so it is admitted +// only on a connection whose selection is already ScopeDecrypt: after a SIGKEY +// it would turn a sign-scoped selection into a decryption. +func (s *session) handleSetKey(line, args string, need Scope) { + p := s.proxy + verb := nameOf(line) + gripArg := strings.TrimSpace(args) + another := false + if need == ScopeDecrypt { + if rest, ok := strings.CutPrefix(gripArg, "--another "); ok { + gripArg = strings.TrimSpace(rest) + another = true + } + } + prev := s.selected + // Any refusal below leaves the agent holding whatever it held, so the + // selection is cleared rather than kept: the next PKSIGN/PKDECRYPT must + // not act on a key this connection did not just choose. + s.selected = 0 + if another && prev != ScopeDecrypt { + p.audit("refused: setkey --another without a key selected by setkey") + writeLine(s.w, "ERR 67108881 No secret key") + return + } + raw, err := parseGrip(strings.ToUpper(verb), gripArg) + if err != nil { + p.audit(fmt.Sprintf("refused: malformed keygrip %q", truncate(gripArg, 60))) + writeLine(s.w, "ERR 67108881 No secret key") + return + } + g, ok := gripOf(p.pinned, raw) + if !ok { + p.audit("refused: " + strings.ToLower(verb) + + " for a keygrip that is not in the pinned set") + writeLine(s.w, "ERR 67108881 No secret key") + return + } + if need&ScopeSign != 0 && !g.sign { + p.audit("refused: " + strings.ToLower(verb) + " for a grip without the sign scope") + writeLine(s.w, "ERR 67108881 No secret key") + return + } + if need&ScopeDecrypt != 0 && !g.decrypt { + // A sign-only profile pins the primary grip with ScopeSign alone, so + // `SETKEY ` lands here. + p.audit("refused: " + strings.ToLower(verb) + " for a grip without the decrypt scope") + writeLine(s.w, "ERR 67108881 No secret key") + return + } + s.selected = need + s.forward(line) + if !another { + what := "sign" + if need == ScopeDecrypt { + what = "decrypt" + } + s.tell("SETKEYDESC A+process+in+a+snug+sandbox+asks+to+" + what + + "+with+your+OpenPGP+key%0Akeygrip+" + strings.ToUpper(g.grip)) + } +} + +// forward relays one command to the session's upstream connection and copies +// the reply back verbatim, until OK or ERR. This is the arm for the commands +// that carry no grip of their own (RESET, SETHASH) and for the terminal PKSIGN. +// +// It is also the arm that reaches the agent with state from earlier commands on +// the same session: the SIGKEY a client sent two lines ago is on THIS +// connection, so PKSIGN finds it. +func (s *session) forward(line string) { + s.exchange(line, false) +} + +// handlePKDECRYPT forwards a decryption request. +// +// Measured against gpg-agent 2.4.4 and 2.5.24, PKDECRYPT is SERVER-DRIVEN: after the client +// sends `PKDECRYPT`, the agent sends +// +// S INQUIRE_MAXLEN 4096 +// INQUIRE CIPHERTEXT +// +// and WAITS for the client to answer with a D line and an END line before it +// produces its own `D (5:value33:...)` and `OK`. +// +// The proxy does not parse or interpret the ciphertext: it relays an opaque +// blob and an opaque value. It therefore cannot say WHAT was decrypted — that +// residual is in the package comment, not a gap this function can close. +// +// The command line is forwarded WITH its option, because the option selects +// what the agent returns. Measured with `gpg --debug ipc`: gpg 2.5.24 sends +// `PKDECRYPT --kem=PGP` for a cv25519 subkey and expects the agent to finish +// the ECDH key derivation itself; gpg 2.4 sends a bare `PKDECRYPT` and does the +// derivation client-side. Dropping the option made every 2.5 decryption fail +// with `Wrong secret key used`. The proxy relays whichever form the client +// sends, so both versions keep the protocol they negotiated with the agent. +func (s *session) handlePKDECRYPT(line, args string) { + if !pkdecryptOptionAllowed(args) { + s.proxy.refuse(s.w, fmt.Sprintf("refused: PKDECRYPT option %q", truncate(args, 40))) + return + } + s.exchange(line, true) +} + +// exchange sends one command upstream and copies the reply to the sandbox +// until OK or ERR. +// +// An INQUIRE from the agent is a question the AGENT asks, and only one is +// relayed: `INQUIRE CIPHERTEXT` during PKDECRYPT, whose answer is the +// ciphertext the sandbox brought. Every other INQUIRE is answered here, never +// shown to the sandbox — PINENTRY_LAUNCHED with END (it names the host +// pinentry's pid and tty), anything else with CAN, which the agent turns into +// its own ERR. +func (s *session) exchange(line string, ciphertext bool) { + p := s.proxy + upR, upW, err := s.upstream() + if err != nil { + p.upstreamUnreachable(s.w, err) + return + } + if err := writeLine(upW, line); err != nil { + p.upstreamUnreachable(s.w, err) + return + } + if err := upW.Flush(); err != nil { + p.upstreamUnreachable(s.w, err) + return + } + total := 0 + for { + reply, err := readLine(upR) + if err != nil { + p.upstreamUnreachable(s.w, err) + return + } + total += len(reply) + if total > maxReply { + s.abort("refused: agent reply exceeded the limit") + return + } + if isInquire(reply) { + if ciphertext && reply == "INQUIRE CIPHERTEXT" { + if err := writeLine(s.w, reply); err != nil { + return + } + if err := s.w.Flush(); err != nil { + return + } + if err := relayInquireAnswer(s.r, upW); err != nil { + s.abort("refused: inquire answer: " + err.Error()) + return + } + continue + } + answer := "CAN" + if strings.HasPrefix(reply, "INQUIRE PINENTRY_LAUNCHED") { + answer = "END" + } else { + p.audit(fmt.Sprintf("cancelled agent inquiry %q", truncate(reply, 60))) + } + if err := writeLine(upW, answer); err != nil { + p.upstreamUnreachable(s.w, err) + return + } + if err := upW.Flush(); err != nil { + p.upstreamUnreachable(s.w, err) + return + } + continue + } + if err := writeLine(s.w, reply); err != nil { + return + } + if isTerminal(reply) { + return + } + } +} + +// tell sends a command of the PROXY's own upstream and discards the reply. It +// is for lines the sandbox did not write and must not see answered. +func (s *session) tell(line string) { + upR, upW, err := s.upstream() + if err != nil { + return + } + if writeLine(upW, line) != nil || upW.Flush() != nil { + return + } + for { + reply, err := readLine(upR) + if err != nil || isTerminal(reply) { + return + } + } +} + +// pkdecryptOptionAllowed is the option set PKDECRYPT may carry: the forms gpg +// sends, measured with `gpg --debug ipc` — bare (2.4), `--kem=PGP` (2.5, an +// ECDH subkey) and `--kem=PQC-PGP` (2.5, a composite Kyber subkey). Anything +// else is refused rather than forwarded. Measured against gpg-agent +// 2.5.24: it also accepts `--kem` and `--kem=CMS`, refuses `--kem=PQC` and +// `--kem=FOO` as "invalid KEM algorithm", and silently IGNORES an option it +// does not know (`PKDECRYPT --bogus` went on to INQUIRE CIPHERTEXT) — so a +// forwarded unknown option would reach a future agent that does know it, +// unreviewed. +func pkdecryptOptionAllowed(args string) bool { + switch strings.TrimSpace(args) { + case "", "--kem=PGP", "--kem=PQC-PGP": + return true + } + return false +} + +// isInquireEnd reports whether a client line ends an INQUIRE answer. Measured: +// gpg-agent 2.5.24 takes `CAN` as a cancel (`ERR 67109141 IPC call has been +// cancelled`), and gpg-connect-agent answers an INQUIRE it has no data for with +// `CANCEL`. A prefix test for "CANCEL" alone missed `CAN` and left the proxy +// waiting while the client waited for the agent. +func isInquireEnd(line string) bool { + return line == "END" || line == "CAN" || line == "CANCEL" +} + +// relayInquireAnswer reads the sandbox's reply to an INQUIRE — D lines, then +// END, or CAN to decline — and hands it to the agent verbatim, refusing any +// other line. The data is +// bounded by maxReply; the TIME is not, because the client is the one supplying +// the ciphertext, and a silent client holds only its own connection, which +// Close ends with the sandbox. +// +// The terminators are matched whole; see isInquireEnd. +func relayInquireAnswer(r *bufio.Reader, upW *bufio.Writer) error { + total := 0 + for { + line, err := readLine(r) + if err != nil { + return err + } + total += len(line) + if total > maxReply { + return errors.New("exceeded the limit") + } + // Only data, END and CAN belong in an INQUIRE answer. Measured on + // gpg-agent 2.5.24: any other line aborts the INQUIRE and every line + // after it runs as an ordinary command — `PKDECRYPT`, `X`, + // `KEYINFO --list`, `END` through this proxy returned the agent's + // unpinned keygrips on the client's next command. So nothing else is + // passed on, and the session ends. + if !isInquireEnd(line) && !strings.HasPrefix(line, "D ") { + _ = writeLine(upW, "CAN") + _ = upW.Flush() + return fmt.Errorf("line %q in an inquire answer", truncate(line, 20)) + } + if err := writeLine(upW, line); err != nil { + return err + } + if err := upW.Flush(); err != nil { + return err + } + if isInquireEnd(line) { + return nil + } + } +} + +// abort ends a session that has lost step with the agent: the client gets a +// terminal ERR, the upstream connection is closed (sticky, so nothing reads the +// rest of its reply), and the sandbox connection is closed after this command. +func (s *session) abort(what string) { + s.proxy.audit(what) + writeLine(s.w, errForbidden) + if s.up != nil { + s.up.Close() + s.up = nil + } + s.upErr = errors.New(what) + s.ended = true +} + +// upstreamUnreachable audits and answers the agent's own failure for "no key +// available". A client that gets this retries or reports it as the agent being +// down, which is what happened. +func (p *Proxy) upstreamUnreachable(w *bufio.Writer, err error) { + p.audit(fmt.Sprintf("upstream gpg-agent unreachable: %v", err)) + writeLine(w, "ERR 67108881 No secret key") +} + +// ── pin-set helpers ───────────────────────────────────────────────────────── + +func (p *Proxy) pinnedRaw() [][20]byte { + out := make([][20]byte, 0, len(p.pinned)) + for _, g := range p.pinned { + out = append(out, g.raw) + } + return out +} + +// containsGrip reports whether raw is one of set, with no early exit: every +// element is compared and the results are OR'd, so the time taken does not say +// WHICH element matched. The secrecy here is nil — keygrips are public +// identifiers, and handleHaveKey hands the whole set to the sandbox on request +// — and the loop is written this way for the reason sshproxy.containsBlob is: +// a variable-time compare in a security filter invites the next reader to copy +// the pattern somewhere it does matter. +func containsGrip(set []grip, raw [20]byte) bool { + match := 0 + for _, g := range set { + match |= subtle.ConstantTimeCompare(raw[:], g.raw[:]) + } + return match == 1 +} + +func gripOf(set []grip, raw [20]byte) (grip, bool) { + var found grip + ok := false + for _, g := range set { + if subtle.ConstantTimeCompare(raw[:], g.raw[:]) == 1 { + found = g + ok = true + } + } + return found, ok +} + +// parseGrip decodes a 40-hex-character keygrip into its 20 bytes. It is +// lenient about case, because gpg prints uppercase in some places and a client +// may too, and strict about length and alphabet, because a grip that is not 20 +// bytes cannot be compared against the pin set. +func parseGrip(field, s string) ([20]byte, error) { + var raw [20]byte + s = strings.TrimSpace(s) + if len(s) != 40 { + return raw, fmt.Errorf("%s: %q is not a keygrip (want 40 hexadecimal characters)", + field, truncate(s, 60)) + } + for i := 0; i < 20; i++ { + hi, ok1 := hexVal(s[2*i]) + lo, ok2 := hexVal(s[2*i+1]) + if !ok1 || !ok2 { + return raw, fmt.Errorf("%s: %q is not a keygrip (want 40 hexadecimal characters)", + field, truncate(s, 60)) + } + raw[i] = hi<<4 | lo + } + return raw, nil +} + +func hexVal(c byte) (byte, bool) { + switch { + case c >= '0' && c <= '9': + return c - '0', true + case c >= 'a' && c <= 'f': + return c - 'a' + 10, true + case c >= 'A' && c <= 'F': + return c - 'A' + 10, true + } + return 0, false +} + +// ── Assuan line helpers ───────────────────────────────────────────────────── + +// assuanEscape percent-escapes a D-line payload: % becomes %25, LF becomes %0A +// and CR becomes %0D, as Assuan requires of every D line, and gpg's fast-path +// listing failed with `Invalid IPC response` through this proxy while it wrote +// a grip's 0x0A byte raw (the contributor's live test). A D line is +// newline-terminated, so an unescaped 0x0A would end the line early and the +// client would read a truncated payload. +// +// The escape SET is deliberately minimal — exactly the characters that would +// otherwise break the framing, plus '%' itself. Escaping anything else (for +// instance every non-printable byte) would still parse, but it would differ +// byte-for-byte from what the agent writes, and the D lines this proxy forwards +// from upstream are copied VERBATIM rather than re-escaped, so matching the +// agent's own habit is what keeps the two indistinguishable. +func assuanEscape(s []byte) string { + if !needsEscape(s) { + return string(s) + } + var b strings.Builder + b.Grow(len(s) + 8) + for _, c := range s { + switch c { + case '%': + b.WriteString("%25") + case '\n': + b.WriteString("%0A") + case '\r': + b.WriteString("%0D") + default: + b.WriteByte(c) + } + } + return b.String() +} + +func needsEscape(s []byte) bool { + for _, c := range s { + if c == '%' || c == '\n' || c == '\r' { + return true + } + } + return false +} + +// assuanUnescape is NOT used by the relay, and that is deliberate: the INQUIRE +// answer is forwarded byte-for-byte. The measurement that settled it: on a real +// decrypt, the client sent +// +// D (7:enc-val(4:ecdh(1:s49:<49 raw bytes>)(1:e33:<33 raw bytes>))) +// +// with no percent-escapes at all, and the agent read it. Escaping in Assuan is +// applied by whoever WRITES a D line, and only the bytes that would break the +// framing need it; a payload with none of them travels raw. The proxy is a byte +// pipe in both directions, so it neither adds nor removes escaping on relayed +// lines — doing so would corrupt a ciphertext whose author chose not to escape. +// +// It is kept, unexported and exercised, because handleHaveKey GENERATES a D line +// and generation is the case that needs the inverse, and a test asserts the +// round trip so the pair cannot drift apart. +func assuanUnescape(s string) []byte { + if !strings.ContainsRune(s, '%') { + return []byte(s) + } + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '%' && i+2 < len(s) { + hi, ok1 := hexVal(s[i+1]) + lo, ok2 := hexVal(s[i+2]) + if ok1 && ok2 { + out = append(out, hi<<4|lo) + i += 2 + continue + } + } + out = append(out, s[i]) + } + return out +} + +// readLine reads one Assuan line (up to \n) and strips the terminator. It is +// bounded by maxLine so a sandbox that never sends a newline cannot exhaust +// snug's memory. +func readLine(r *bufio.Reader) (string, error) { + var sb strings.Builder + for { + chunk, more, err := r.ReadLine() + if err != nil { + return "", err + } + sb.Write(chunk) + if sb.Len() > maxLine { + return "", errors.New("assuan line too long") + } + if !more { + return sb.String(), nil + } + } +} + +func writeLine(w *bufio.Writer, s string) error { + if _, err := w.WriteString(s); err != nil { + return err + } + return w.WriteByte('\n') +} + +// isTerminal reports whether an Assuan reply line ends the server's response +// for the current command. +func isTerminal(line string) bool { + return line == "OK" || strings.HasPrefix(line, "OK ") || + line == "ERR" || strings.HasPrefix(line, "ERR ") +} + +// isInquire reports whether a line from the agent is an INQUIRE the client must +// answer. The keyword is `INQUIRE `; `INQUIRE_MAXLEN` rides alongside it as an +// S line and is relayed like any other non-terminal line. +func isInquire(line string) bool { + return strings.HasPrefix(line, "INQUIRE ") || line == "INQUIRE" +} + +// splitCommand splits an Assuan line into the command keyword and the rest. +func splitCommand(line string) (name, args string) { + if i := strings.IndexByte(line, ' '); i >= 0 { + return line[:i], line[i+1:] + } + return line, "" +} + +func nameOf(line string) string { + name, _ := splitCommand(line) + return name +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// hexString renders bytes as lowercase hex, the form gpg prints and accepts. +func hexString(b []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, 0, len(b)*2) + for _, c := range b { + out = append(out, digits[c>>4], digits[c&0x0f]) + } + return string(out) +} + +// concatGrips joins raw keygrips into one byte string, the HAVEKEY --list +// payload before escaping. +func concatGrips(b [][20]byte) []byte { + out := make([]byte, 0, len(b)*20) + for _, r := range b { + out = append(out, r[:]...) + } + return out +} + +func atoiSafe(s string) (int, error) { + n := 0 + if s == "" { + return 0, errors.New("empty") + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, errors.New("not a number") + } + n = n*10 + int(s[i]-'0') + if n > maxReply { + return 0, errors.New("too large") + } + } + return n, nil +} + +func probeSetOptions(w *bufio.Writer, r *bufio.Reader, getenv func(string) string) { + // The startup probe's pinentry, if the key is locked, belongs on snug's own + // terminal or display: the sandbox does not exist yet. The options are + // per-connection; nothing here changes the agent's startup environment. + setOption := func(opt string) { + _ = writeLine(w, "OPTION "+opt) + _ = w.Flush() + _, _ = readLine(r) // OK, or ERR for an option this agent lacks + } + if v := getenv("GPG_TTY"); v != "" { + setOption("ttyname=" + v) + } + if v := getenv("TERM"); v != "" { + setOption("ttytype=" + v) + } + if v := getenv("DISPLAY"); v != "" { + setOption("display=" + v) + } + if v := getenv("XAUTHORITY"); v != "" { + setOption("xauthority=" + v) + } + // Measured on gpg-agent 2.5.24, `OPTION DISPLAY=:9` is `Unknown option`; + // the variables without an OPTION name of their own travel as putenv. + for _, key := range []string{"WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"} { + if v := getenv(key); v != "" { + setOption("putenv=" + key + "=" + v) + } + } +} + +// ── the startup probe ─────────────────────────────────────────────────────── + +// probeTimeout bounds the whole startup exchange — dial, write, read. On a +// live agent this is sub-millisecond over a unix socket; the value is loose +// because it exists to bound a hung or unresponsive socket, not to tune a fast +// path, and the cost is only ever paid on a host that is already broken. +const probeTimeout = 5 * time.Second + +// probeUpstream asks the host agent ONCE, before the sandbox exists, whether it +// holds every pinned grip, and refuses the run when it does not. +// +// WHY THIS IS NOT OPTIONAL. snug authors the thing that makes the failure +// fatal: with identity.gpg it writes `gpg.format = openpgp` and +// `user.signingkey` into the generated ~/.gitconfig, so a grip the agent does +// not hold turns every signed commit inside into a hard failure whose error +// names libcrypto or a failed key lookup, not a missing pin. The sandbox cannot +// see the host agent's contents, so the diagnosis is unavailable at the layer +// where the failure appears. It is available here. That is invariant 5 in its +// literal form, and it is why this lives in New rather than behind a method a +// caller can skip. +// +// It contacts the upstream agent, which the sandbox-initiated HAVEKEY path +// deliberately never does. The distinction survives: that path answers locally +// so the agent's other grips are never enumerated FOR THE SANDBOX. This is snug +// asking on the human's behalf, before a sandbox exists, and nothing it learns +// reaches one — the only thing said about the agent's contents in any refusal +// is whether the PINNED grip is among them. +func probeUpstream(upstream string, pins []grip, getenv func(string) string) error { + deadline := time.Now().Add(probeTimeout) + conn, err := (&net.Dialer{Deadline: deadline}).Dial("unix", upstream) + if err != nil { + return probeNoAnswer(upstream, err) + } + defer conn.Close() + if err := conn.SetDeadline(deadline); err != nil { + return probeNoAnswer(upstream, err) + } + r := bufio.NewReaderSize(conn, 4096) + w := bufio.NewWriter(conn) + // Greeting. + for { + line, err := readLine(r) + if err != nil { + return probeNoAnswer(upstream, err) + } + if isTerminal(line) { + break + } + } + // Set up TTY/display context so the agent can spawn pinentry correctly, + // just like the gpg CLI does. Without it a new Assuan connection (from a + // process other than gpg) can fail to start a GUI or tty pinentry with + // "Inappropriate ioctl for device". + probeSetOptions(w, r, getenv) + // One HAVEKEY per grip: the multi-grip form answers a single ERR when ANY + // is missing, which would not say WHICH pin the profile got wrong. + for _, g := range pins { + if err := writeLine(w, "HAVEKEY "+g.grip); err != nil { + return probeNoAnswer(upstream, err) + } + if err := w.Flush(); err != nil { + return probeNoAnswer(upstream, err) + } + line, err := readLine(r) + if err != nil { + return probeNoAnswer(upstream, err) + } + if strings.HasPrefix(line, "OK") { + continue + } + return fmt.Errorf("the host gpg-agent does not hold the keygrip for %s (%s).\n\n"+ + " That grip is derived from the fingerprint the profile pins. The PRIVATE\n"+ + " half must be in the agent on the HOST, because no key material ever enters\n"+ + " the sandbox. The agent answered:\n"+ + " %s\n"+ + " Load the key, or check it is not on a smartcard:\n"+ + " gpg --list-secret-keys --with-keygrip \n"+ + " Or remove identity.gpg from the profile.", + g.field, g.grip, strings.TrimSpace(line)) + } + + // Phase 2, on the SAME connection: membership was not enough for the keys + // snug's own generated config makes mandatory. A grip the agent LISTS but + // will not USE fails every commit inside with an error naming no cause. The + // probe proves the agent ANSWERS for that grip, which is the failure + // measured; it is startup state, not a guarantee. + for _, g := range pins { + if !g.sign { + continue + } + if err := probeSign(conn, r, w, g); err != nil { + return err + } + } + return nil +} + +// signProbePrefix domain-separates the probe digest so the signature snug gets +// back is not useful as a signature over anything else; snug also discards it. +// The bytes hashed are this 17-byte prefix +// followed by 32 bytes from crypto/rand: fresh per key, per run, and not +// influenced by anything outside this function. +// +// A verifier of an OpenPGP signature does not PARSE the signed blob for the +// meaning of the signature; it hashes the payload it expects and checks the +// signature over that hash. So the property needed is that no payload a +// verifier constructs can EQUAL these bytes. The prefix is ASCII "snug-agent-probe" +// and a NUL, and an OpenPGP signature over a normal document (a git commit, an +// email) hashes that document verbatim — so the only way to construct a payload +// equal to this one is to already possess these exact 49 bytes, which no party +// outside this process does. +const signProbePrefix = "snug-agent-probe\x00" + +const signProbeNonceBytes = 32 + +// signProbeTimeout bounds the SIGN phase, and it is deliberately not +// probeTimeout. A pinentry prompt on the host can block the client for as long +// as the human takes to answer; probeTimeout (5 s) would abort exactly the case +// the sign probe exists to serve — a human being asked, on the host, to confirm +// — and turn a working setup into a refusal. Two minutes bounds a hung agent; +// it does not pace a human, and nothing waits on it when the answer is no. +const signProbeTimeout = 2 * time.Minute + +// probeSign asks the agent for one signature with a grip snug's generated +// config makes mandatory. The reply is INSPECTED AND DROPPED: nothing derived +// from it is stored, audited, written or sent to any client. The signature is +// over bytes that mean nothing to anyone, and the only thing snug wants is that +// it exists. +// +// It sends SIGKEY, SETKEYDESC, SETHASH and PKSIGN exactly as a client would +// (measured order), so a host that refuses the sequence refuses the probe for +// the same reason it would refuse the real thing. +// +// It reads and writes through the probe's own r and w: a second bufio.Reader on +// the same conn would lose anything the first had already buffered. +func probeSign(conn net.Conn, r *bufio.Reader, w *bufio.Writer, g grip) error { + nonce := make([]byte, signProbeNonceBytes) + if _, err := rand.Read(nonce); err != nil { + return fmt.Errorf("generating the startup signature probe for %s: %w", g.field, err) + } + // SETHASH takes a DIGEST, and is the OpenPGP hash id: + // 10 is SHA-512, so the digest must be 64 bytes. A 32-byte digest under id + // 10 happened to sign with ed25519 and was refused by a brainpoolP384r1 + // key with `ERR 67109003 Invalid length` (measured, gpg-agent 2.5.24). + // SHA-512 is long enough for every curve and RSA size gpg generates. + sum := sha512.Sum512(append([]byte(signProbePrefix), nonce...)) + digest := sum[:] + + if err := conn.SetDeadline(time.Now().Add(signProbeTimeout)); err != nil { + return probeSignNoAnswer(g, err) + } + expectOK := func(cmd string) error { + if err := writeLine(w, cmd); err != nil { + return err + } + if err := w.Flush(); err != nil { + return err + } + line, err := readLine(r) + if err != nil { + return err + } + if !strings.HasPrefix(line, "OK") { + return fmt.Errorf("agent answered %q", strings.TrimSpace(line)) + } + return nil + } + start := time.Now() + if err := expectOK("RESET"); err != nil { + return probeSignNoAnswer(g, err) + } + if err := expectOK("SIGKEY " + strings.ToUpper(g.grip)); err != nil { + return probeSignSignRefused(g, err, time.Since(start)) + } + if err := expectOK("SETKEYDESC snug-agent-probe"); err != nil { + return probeSignNoAnswer(g, err) + } + if err := expectOK("SETHASH 10 " + strings.ToUpper(hexString(digest))); err != nil { + return probeSignNoAnswer(g, err) + } + // PKSIGN: read until OK/ERR, then look for a D line carrying sig-val. + if err := writeLine(w, "PKSIGN"); err != nil { + return probeSignNoAnswer(g, err) + } + if err := w.Flush(); err != nil { + return probeSignNoAnswer(g, err) + } + sawData := false + for { + line, err := readLine(r) + if err != nil { + return probeSignNoAnswer(g, err) + } + if strings.HasPrefix(line, "D ") { + sawData = true + } + if strings.HasPrefix(line, "ERR") { + return probeSignSignRefused(g, fmt.Errorf("agent answered %q", strings.TrimSpace(line)), + time.Since(start)) + } + if strings.HasPrefix(line, "OK") { + break + } + } + if !sawData { + return fmt.Errorf("the host gpg-agent answered PKSIGN for %s (%s) without a signature.\n\n"+ + " snug asks for ONE signature at startup, over a probe digest of its own,\n"+ + " because it authors `gpg.format = openpgp` and `commit.gpgsign = true` in the\n"+ + " sandbox's ~/.gitconfig: a key the agent will not USE fails every commit inside.\n"+ + " Reproduce by hand: echo snug | gpg --detach-sign -u \n"+ + " Or remove identity.gpg from the profile.", + g.field, g.grip, g.field) + } + return nil +} + +func probeSignSignRefused(g grip, cause error, elapsed time.Duration) error { + return fmt.Errorf("the host gpg-agent holds %s (%s) but refused to sign with it: %v.\n\n"+ + " snug asks for ONE signature at startup, over a probe digest of its own, because\n"+ + " it authors `commit.gpgsign = true` inside. A key the agent LISTS but will not\n"+ + " USE fails every signed commit inside with an error naming no cause.\n"+ + " The agent answered after %s.\n"+ + " Reproduce by hand: echo snug | gpg --detach-sign -u \n"+ + " Or remove identity.gpg from the profile.", + g.field, g.grip, cause, elapsed.Round(time.Millisecond), g.field) +} + +func probeSignNoAnswer(g grip, err error) error { + return fmt.Errorf("the host gpg-agent did not answer a signature request for %s (%s) "+ + "within %s: %w.\n\n"+ + " snug asks for one signature at startup because it authors `commit.gpgsign = true`\n"+ + " inside, and a key that cannot sign fails every commit with an error naming no\n"+ + " cause. If a pinentry dialog is open on the host, answer it and re-run.\n"+ + " Check by hand: echo snug | gpg --detach-sign -u %s\n"+ + " Or remove identity.gpg from the profile.", + g.field, g.grip, signProbeTimeout, err, g.field) +} + +func probeNoAnswer(upstream string, err error) error { + return fmt.Errorf("the host gpg-agent at %s did not answer: %w.\n\n"+ + " snug asks the agent once, at startup, whether it holds the keygrip the profile\n"+ + " pins. A sandbox that believes it can sign and cannot fails later, INSIDE, as an\n"+ + " error from git or gpg that names no cause.\n"+ + " Check the agent is alive: gpg-connect-agent 'GETINFO version' /bye", + upstream, err) +} diff --git a/internal/gpgproxy/proxy_test.go b/internal/gpgproxy/proxy_test.go new file mode 100644 index 00000000..b521183e --- /dev/null +++ b/internal/gpgproxy/proxy_test.go @@ -0,0 +1,643 @@ +package gpgproxy + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// Grip fixtures. Deterministic and DISTINCT so two grips in one test are never +// accidentally equal, and a failing assertion's diff is reproducible. +const ( + gripPrimary = "2761D2BF75F0095F0FEA0A9216F4114A3B0C20BA" + gripSubkey = "80C95D6427C02FBB78C91DCEE6E12BD520EF66F0" + gripOther = "0000000000000000000000000000000000000001" +) + +// fakeAgent stands in for the host's gpg-agent and records whether it was +// contacted at all. "The proxy refused" and "the proxy asked upstream and +// relayed a refusal" look identical to the client — only this distinguishes +// them, and the difference is the whole point of answering locally. +// +// holds is deliberately allowed to be WIDER than what the proxy pins: that is +// what makes the leak assertions in this file real assertions rather than ones +// that would pass against an agent with nothing to leak. +type fakeAgent struct { + path string + ln net.Listener + contacts atomic.Int32 + + mu sync.Mutex + // holds is the set of grips this fake agent has. Every command that + // names a grip is checked against it, exactly as the real agent does. + holds map[string]bool + // signCalls and dekryptCalls record what actually crossed the wire. + signCalls []string + decryptCalls []string + // b64Marker is returned as the sig-val/value payload so a test can look + // for it turning up somewhere it must not. + sigMarker []byte + // exported records every EXPORT_KEY the fake agent saw. A non-nil entry + // is a test failure anywhere it appears: the proxy must never forward it. + exported []string + // scd records every SCD verb the fake agent saw. `SCD SERIALNO` is expected + // on the normal path; other SCD verbs are not. + scd []string + // pkdecrypt records every PKDECRYPT command line as it arrived, options + // included: the option selects what the agent returns. + pkdecrypt []string + // inquire records every line the fake agent read while waiting for an + // INQUIRE answer — the client's D payload and its END. + inquire []string + // refuseSign marks a grip as held (HAVEKEY answers OK) but unsignable + // (SIGKEY or PKSIGN answers ERR), standing in for a key the agent lists + // and will not use — a smartcard-backed key with no card, a locked agent, + // a passphrase prompt declined. + refuseSign map[string]bool + // options records every OPTION line the fake agent received, on any + // connection. The proxy's own startup probe sends some, so a test that + // asks "did the sandbox's OPTION reach the agent" compares a snapshot + // taken before with one taken after. + options []string + // setkeydesc records every SETKEYDESC line, for the same reason: the + // proxy writes its own after an admitted SIGKEY/SETKEY, and the sandbox's + // must never arrive. + setkeydesc []string + // preInquire is a list of INQUIRE keywords the fake agent asks before it + // answers PKSIGN, as the real agent asks PINENTRY_LAUNCHED around a + // pinentry and PASSPHRASE in loopback mode. Each answer the proxy gives + // lands in inquireAnswers; anything but END cancels the PKSIGN. + preInquire []string + inquireAnswers []string + // bigReply makes PKSIGN answer with more data than the proxy's maxReply. + bigReply bool +} + +func (a *fakeAgent) setPreInquire(keywords ...string) { + a.mu.Lock() + defer a.mu.Unlock() + a.preInquire = keywords +} + +func (a *fakeAgent) setBigReply() { + a.mu.Lock() + defer a.mu.Unlock() + a.bigReply = true +} + +// snapshot helpers: a copy of a recorded list under the lock. +func (a *fakeAgent) sawOptions() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.options...) +} + +func (a *fakeAgent) sawSetKeyDesc() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.setkeydesc...) +} + +func (a *fakeAgent) sawInquireAnswers() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.inquireAnswers...) +} + +// addTo appends to one of the recorded lists under the lock. +func (a *fakeAgent) addTo(list *[]string, s string) { + a.mu.Lock() + defer a.mu.Unlock() + *list = append(*list, s) +} + +// setRefuseSign marks grip as listed-but-unsignable. +func (a *fakeAgent) setRefuseSign(g string) { + a.mu.Lock() + defer a.mu.Unlock() + if a.refuseSign == nil { + a.refuseSign = map[string]bool{} + } + a.refuseSign[strings.ToUpper(g)] = true +} + +func (a *fakeAgent) refusesSign(g string) bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.refuseSign[strings.ToUpper(g)] +} + +// shortDir is a fresh directory under /tmp. t.TempDir() embeds the test's name, +// and with a subtest's "SETKEY 0000..." in it the socket path passes AF_UNIX's +// 108-byte sun_path, so the bind fails on the fixture and not on the code +// under test. +func shortDir(t *testing.T) string { + t.Helper() + d, err := os.MkdirTemp("", "gp") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(d) }) + return d +} + +func newFakeAgent(t *testing.T, holds ...string) *fakeAgent { + t.Helper() + path := filepath.Join(shortDir(t), "upstream.sock") + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + a := &fakeAgent{path: path, ln: ln, holds: map[string]bool{}} + for _, g := range holds { + a.holds[strings.ToUpper(g)] = true + } + a.sigMarker = []byte("sig-marker") + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + a.contacts.Add(1) + go a.serve(c) + } + }() + t.Cleanup(func() { ln.Close() }) + return a +} + +// serve implements enough of the Assuan dialogue to exercise the proxy: the +// greeting, HAVEKEY, KEYINFO, SIGKEY, SETKEY, SETKEYDESC, SETHASH, PKSIGN, +// PKDECRYPT (with the server-driven INQUIRE) and RESET. Everything a real +// client sends that the proxy must never forward is recognized only so the fake +// can record it. +func (a *fakeAgent) serve(c net.Conn) { + defer c.Close() + r := bufio.NewReaderSize(c, 4096) + w := bufio.NewWriter(c) + line := func(s string) { writeLine(w, s); w.Flush() } + line("OK Pleased to meet you, process 0") + // selected tracks the grip a SIGKEY/SETKEY set on THIS connection, which is + // the whole reason the proxy must keep one upstream connection open. + selected := "" + for { + req, err := readLine(r) + if err != nil { + return + } + name, args := splitCommand(req) + switch strings.ToUpper(name) { + case "RESET": + selected = "" + line("OK") + case "OPTION": + a.addTo(&a.options, req) + line("OK") + case "GETINFO": + // The host-state answers are what a real agent gives, so a test + // that they never reach the sandbox has something to leak. + switch strings.TrimSpace(args) { + case "version": + line("D 2.4.8") + case "socket_name": + line("D " + a.path) + case "pid": + line("D 4242") + } + line("OK") + case "HAVEKEY": + a.handleHaveKey(line, args) + case "KEYINFO": + a.handleKeyInfo(line, args) + case "SIGKEY", "SETKEY": + g := strings.TrimSpace(args) + if strings.ToUpper(name) == "SETKEY" { + g, _ = strings.CutPrefix(g, "--another ") + } + g = strings.ToUpper(g) + if !a.holdsGrip(g) { + line("ERR 67108881 No secret key") + continue + } + selected = g + line("OK") + case "SETKEYDESC": + a.addTo(&a.setkeydesc, req) + line("OK") + case "SETHASH": + // The real agent checks the digest against the OpenPGP hash id: a + // SHA-256-sized digest under id 10 (SHA-512) is refused by a + // brainpoolP384r1 key with `Invalid length` (measured, 2.5.24). + if f := strings.Fields(args); len(f) == 2 && f[0] == "10" && len(f[1]) != 128 { + line("ERR 67109003 Invalid length ") + continue + } + line("OK") + case "PKSIGN": + if selected == "" { + line("ERR 67108881 No secret key") + continue + } + if a.refusesSign(selected) { + line("ERR 67108881 No secret key") + continue + } + cancelled := false + a.mu.Lock() + asks := append([]string(nil), a.preInquire...) + big := a.bigReply + a.mu.Unlock() + for _, q := range asks { + line("INQUIRE " + q) + ans, err := readLine(r) + if err != nil { + return + } + a.addTo(&a.inquireAnswers, ans) + if ans != "END" { + line("ERR 67109141 IPC call has been cancelled") + cancelled = true + break + } + } + if cancelled { + continue + } + if big { + // 20 lines of 60 KB: past maxReply (1 MiB), each under maxLine. + chunk := strings.Repeat("A", 60*1024) + for range 20 { + if _, err := w.WriteString("D " + chunk + "\n"); err != nil { + return + } + } + w.Flush() + } + a.recordSign(selected) + line("D (7:sig-val(5:eddsa(1:r32:" + string(a.sigMarker) + ")(1:s32:x)))") + line("OK") + case "PKDECRYPT": + a.recordPkdecryptLine(req) + if selected == "" { + line("ERR 67108881 No secret key") + continue + } + a.recordDecrypt(selected) + // THE SERVER-DRIVEN INQUIRE, exactly as measured: the agent asks + // the client for the ciphertext and waits. + line("S INQUIRE_MAXLEN 4096") + line("INQUIRE CIPHERTEXT") + w.Flush() + // Read the client's D ... / END. + sawD, sawEnd := false, false + for { + reply, err := readLine(r) + if err != nil { + return + } + a.recordInquire(reply) + if strings.HasPrefix(reply, "D ") { + sawD = true + } + if reply == "END" { + sawEnd = true + break + } + // Exact, as the real agent matches them: `CAN` and `CANCEL` + // both end the inquiry, and a looser prefix match here would + // hide a proxy that forwards CANCELLED as a cancel. + if reply == "CAN" || reply == "CANCEL" { + break + } + } + if !sawD || !sawEnd { + line("ERR 67109141 IPC call has been cancelled") + continue + } + line("D (5:value33:" + string(a.sigMarker) + ")") + line("OK") + case "SCD": + // A real agent without a smartcard answers `SCD SERIALNO` with + // `ERR 67108983 No SmartCard daemon`, and that answer is part of + // gpg's normal path. Record it separately from the mutation verbs + // so a test can tell the two apart. + a.recordScd(req) + line("ERR 67108983 No SmartCard daemon") + case "EXPORT_KEY", "KEYWRAP_KEY", "IMPORTKEYS", "DELETEKEY", "PASSPHRASE", + "SETEVENTS", "SETINFO": + // Recorded so a test can prove these never arrived. + a.recordExport(req) + line("ERR 67108881 No secret key") + case "BYE": + line("OK closing connection") + return + default: + line("ERR 0 unknown") + } + } +} + +func (a *fakeAgent) handleHaveKey(line func(string), args string) { + if rest, ok := strings.CutPrefix(args, "--list="); ok { + var out []byte + for g := range a.holds { + out = append(out, []byte(g)...) + } + _ = rest + if len(out) > 0 { + // The count prefix the real agent uses: one decimal length then + // raw bytes. A client that does not parse the count still gets the + // grips. + line(fmt.Sprintf("D %d%s", len(out), out)) + } + line("OK") + return + } + // OK when ANY named grip is held, as the real agent answers (measured by + // the maintainer on 2.4.4 and 2.5.24: `HAVEKEY 0000...` is OK). + for _, g := range strings.Fields(args) { + if a.holdsGrip(strings.ToUpper(g)) { + line("OK") + return + } + } + line("ERR 67108881 No secret key") +} + +func (a *fakeAgent) handleKeyInfo(line func(string), args string) { + if strings.TrimSpace(args) == "--list" { + for g := range a.holds { + line("S KEYINFO " + g + " D - - - C - - -") + } + line("OK") + return + } + for _, g := range strings.Fields(args) { + if !a.holdsGrip(strings.ToUpper(g)) { + line("ERR 67108891 Not found") + return + } + line("S KEYINFO " + strings.ToUpper(g) + " D - - - C - - -") + } + line("OK") +} + +func (a *fakeAgent) holdsGrip(g string) bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.holds[g] +} + +func (a *fakeAgent) recordSign(g string) { + a.mu.Lock() + defer a.mu.Unlock() + a.signCalls = append(a.signCalls, g) +} + +func (a *fakeAgent) recordDecrypt(g string) { + a.mu.Lock() + defer a.mu.Unlock() + a.decryptCalls = append(a.decryptCalls, g) +} + +func (a *fakeAgent) recordPkdecryptLine(req string) { + a.mu.Lock() + defer a.mu.Unlock() + a.pkdecrypt = append(a.pkdecrypt, req) +} + +func (a *fakeAgent) sawPkdecryptLines() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.pkdecrypt...) +} + +func (a *fakeAgent) recordExport(req string) { + a.mu.Lock() + defer a.mu.Unlock() + a.exported = append(a.exported, req) +} + +func (a *fakeAgent) recordScd(req string) { + a.mu.Lock() + defer a.mu.Unlock() + a.scd = append(a.scd, req) +} + +func (a *fakeAgent) recordInquire(line string) { + a.mu.Lock() + defer a.mu.Unlock() + a.inquire = append(a.inquire, line) +} + +func (a *fakeAgent) sawDecryptInquire() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.inquire...) +} + +// waitDecryptInquire is sawDecryptInquire once the agent has read a line that +// ends the inquiry. The proxy writes its CAN upstream and answers the client +// without waiting for the agent to read it, so a test that drains the client +// can otherwise look before the agent's goroutine has recorded anything. +func (a *fakeAgent) waitDecryptInquire(t *testing.T) []string { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + got := a.sawDecryptInquire() + if n := len(got); n > 0 && (got[n-1] == "END" || got[n-1] == "CAN" || got[n-1] == "CANCEL") { + return got + } + if time.Now().After(deadline) { + return got + } + time.Sleep(time.Millisecond) + } +} + +func (a *fakeAgent) sawScd() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.scd...) +} + +func (a *fakeAgent) sawSign() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.signCalls...) +} + +func (a *fakeAgent) sawDecrypt() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.decryptCalls...) +} + +func (a *fakeAgent) sawExport() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.exported...) +} + +func (a *fakeAgent) contactCount() int32 { return a.contacts.Load() } + +// ── the test client ───────────────────────────────────────────────────────── + +// client is a scripted Assuan client against the proxy. send writes one command +// and reads the reply up to OK/ERR; the reply is returned line by line. +type client struct { + t *testing.T + conn net.Conn + r *bufio.Reader + w *bufio.Writer +} + +func dial(t *testing.T, socketPath string) *client { + t.Helper() + c, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close() }) + cl := &client{t: t, conn: c, r: bufio.NewReaderSize(c, 4096), w: bufio.NewWriter(c)} + // Greeting. + line, err := readLine(cl.r) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(line, "OK") { + t.Fatalf("greeting was %q, want OK", line) + } + return cl +} + +// send writes one command and returns every reply line through the terminal +// OK/ERR, the terminal line included. +func (c *client) send(cmd string) []string { + c.t.Helper() + if err := writeLine(c.w, cmd); err != nil { + c.t.Fatal(err) + } + if err := c.w.Flush(); err != nil { + c.t.Fatal(err) + } + var out []string + for { + line, err := readLine(c.r) + if err != nil { + c.t.Fatalf("reading reply to %q: %v", cmd, err) + } + out = append(out, line) + if isTerminal(line) { + return out + } + } +} + +// answerInquire reads an INQUIRE from the proxy and answers it with one D and +// an END, standing in for a real gpg client mid-PKDECRYPT. +func (c *client) answerInquire(data string) []string { + c.t.Helper() + var out []string + sawInquire := false + for { + line, err := readLine(c.r) + if err != nil { + c.t.Fatalf("reading inquire: %v", err) + } + out = append(out, line) + if isInquire(line) { + sawInquire = true + if err := writeLine(c.w, "D "+data); err != nil { + c.t.Fatal(err) + } + if err := writeLine(c.w, "END"); err != nil { + c.t.Fatal(err) + } + if err := c.w.Flush(); err != nil { + c.t.Fatal(err) + } + continue + } + if isTerminal(line) { + if !sawInquire { + c.t.Fatalf("PKDECRYPT finished without an INQUIRE; got %v", out) + } + return out + } + } +} + +func terminalOf(reply []string) string { + if len(reply) == 0 { + return "" + } + return reply[len(reply)-1] +} + +// auditLog collects the proxy's audit strings for inspection. +type auditLog struct { + mu sync.Mutex + audits []string +} + +func (l *auditLog) add(s string) { + l.mu.Lock() + defer l.mu.Unlock() + l.audits = append(l.audits, s) +} + +// contains reports whether any audit line contains sub. +func (l *auditLog) contains(sub string) bool { + l.mu.Lock() + defer l.mu.Unlock() + for _, a := range l.audits { + if strings.Contains(a, sub) { + return true + } + } + return false +} + +func (l *auditLog) lines() []string { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string(nil), l.audits...) +} + +// newTestProxy builds a proxy against a fake agent. grips are given as +// PinnedGrip values so a test can set a scope per grip. +func newTestProxy(t *testing.T, a *fakeAgent, grips []PinnedGrip) (*Proxy, string, *auditLog) { + t.Helper() + socketPath := filepath.Join(shortDir(t), "S.gpg-agent") + log := &auditLog{} + p, err := New(grips, a.path, socketPath, log.add) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(p.Close) + go p.Serve() + waitForSocket(t, socketPath) + return p, socketPath, log +} + +func waitForSocket(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if c, err := net.Dial("unix", path); err == nil { + c.Close() + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("proxy socket %s never became dialable", path) +} diff --git a/internal/gpgproxy/realagent_test.go b/internal/gpgproxy/realagent_test.go new file mode 100644 index 00000000..2341938b --- /dev/null +++ b/internal/gpgproxy/realagent_test.go @@ -0,0 +1,409 @@ +package gpgproxy + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestRealAgentSignAndDecrypt drives the proxy with the host's own gpg and +// gpg-agent, so the dialogue under test is the one the installed GnuPG speaks +// rather than the one the fake agent was written from. The two differ by +// version: gpg 2.5 sends `PKDECRYPT --kem=PGP` where 2.4 sends a bare +// PKDECRYPT, and a proxy that dropped the option passed every fake-agent test +// while failing every 2.5 decryption with `Wrong secret key used`. +// +// Two homedirs: `agent` holds the secret keys and runs the real agent, and +// `client` holds only the pinned key's public half and finds the PROXY where +// gpgconf says its agent socket is — the same split snug makes between the +// host and the sandbox. +// +// The pinned set is what internal/cli/gpgidentity.go pins and this file +// replicates, because the packages do not import each other: signing SUBKEYS +// for sign, encryption subkeys for decrypt, and never the primary, which +// certifies. The agent also holds a second key the profile did not pin, and +// the negatives are measured against that key and the primary, each with a +// control that the agent itself would have done the thing. +// +// What this does not cover: a pinentry. The generated keys have no passphrase, +// so the agent never asks one. +func TestRealAgentSignAndDecrypt(t *testing.T) { + if testing.Short() { + t.Skip("starts a real gpg-agent") + } + for _, bin := range []string{"gpg", "gpgconf", "gpg-agent", "gpg-connect-agent"} { + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("%s not on PATH", bin) + } + } + ecc := [][]string{ + {"--quick-gen-key", "Pinned ", "ed25519", "cert", "never"}, + {"--quick-add-key", "{fpr}", "ed25519", "sign", "never"}, + {"--quick-add-key", "{fpr}", "cv25519", "encr", "never"}, + } + t.Run("ecc sign+decrypt", func(t *testing.T) { realAgentRoundTrip(t, ecc, true, true) }) + t.Run("ecc sign only", func(t *testing.T) { realAgentRoundTrip(t, ecc, true, false) }) + t.Run("ecc decrypt only", func(t *testing.T) { realAgentRoundTrip(t, ecc, false, true) }) + t.Run("pqc decrypt", func(t *testing.T) { + // gpg 2.5's composite Kyber subkey: two grips on one `grp:` line, + // `SETKEY --another` for the second, `PKDECRYPT --kem=PQC-PGP`. A pqc + // key has no signing subkey, so this is decrypt-only. It is skipped + // only where this gpg does not list Kyber among its algorithms; where + // it does, a failure to generate is a failure. + out, _ := exec.Command("gpg", "--version").Output() + if !strings.Contains(string(out), "Kyber") { + t.Skip("this gpg lists no Kyber algorithm in `gpg --version`") + } + realAgentRoundTrip(t, [][]string{ + {"--quick-gen-key", "Pinned ", "pqc", "default", "never"}, + }, false, true) + }) +} + +// agentRig is the two homedirs and the helpers to drive gpg in either. +type agentRig struct { + t *testing.T + dir, agent, client string + nopass []string + run func(home string, stdin []byte, args ...string) (string, string, error) + must func(home string, stdin []byte, args ...string) string +} + +func newAgentRig(t *testing.T) *agentRig { + t.Helper() + // Short: the proxy socket lands in the client homedir's socket dir, and + // t.TempDir() carries the test and subtest names. + dir, err := os.MkdirTemp("", "ra") + if err != nil { + t.Fatal(err) + } + r := &agentRig{t: t, dir: dir, agent: filepath.Join(dir, "a"), client: filepath.Join(dir, "c")} + for _, d := range []string{r.agent, r.client} { + if err := os.Mkdir(d, 0o700); err != nil { + t.Fatal(err) + } + } + r.run = func(home string, stdin []byte, args ...string) (string, string, error) { + t.Helper() + cmd := exec.Command(args[0], append([]string{"--homedir", home}, args[1:]...)...) + cmd.Env = append(os.Environ(), "GNUPGHOME="+home, "LC_ALL=C") + cmd.Stdin = bytes.NewReader(stdin) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + return out.String(), errb.String(), err + } + r.must = func(home string, stdin []byte, args ...string) string { + t.Helper() + out, errs, err := r.run(home, stdin, args...) + if err != nil { + t.Fatalf("%v: %v\n%s", args, err, errs) + } + return out + } + t.Cleanup(func() { + for _, h := range []string{r.agent, r.client} { + r.run(h, nil, "gpgconf", "--kill", "gpg-agent") + r.run(h, nil, "gpgconf", "--remove-socketdir") + } + os.RemoveAll(dir) + }) + r.nopass = []string{"--batch", "--pinentry-mode", "loopback", "--passphrase", ""} + return r +} + +// gpgAgent runs gpg in the agent homedir with the batch/no-passphrase flags. +func (r *agentRig) gpgAgent(args ...string) (string, string, error) { + return r.run(r.agent, nil, append(append([]string{"gpg"}, r.nopass...), args...)...) +} + +// generate makes a key from steps (each a gpg argument list, "{fpr}" standing +// for the key made by the first) in the agent homedir and returns its +// fingerprint and the grips snug would pin: the signing subkeys' and the +// encryption subkeys'. The primary's grip is returned separately by primaryGrip. +func (r *agentRig) generate(steps [][]string) (fpr string, signers, encryptors []string) { + r.t.Helper() + if _, errs, err := r.gpgAgent(steps[0]...); err != nil { + r.t.Fatalf("%v: %v\n%s", steps[0], err, errs) + } + fpr = r.fprOfUID(steps[0][1]) + for _, step := range steps[1:] { + args := make([]string, len(step)) + for i, a := range step { + args[i] = strings.ReplaceAll(a, "{fpr}", fpr) + } + if _, errs, err := r.gpgAgent(args...); err != nil { + r.t.Fatalf("%v: %v\n%s", args, err, errs) + } + } + signers, encryptors = r.subkeyGrips(fpr) + return fpr, signers, encryptors +} + +// fprOfUID is the primary fingerprint of the key whose user ID is uid. +func (r *agentRig) fprOfUID(uid string) string { + r.t.Helper() + out := r.must(r.agent, nil, "gpg", "--with-colons", "--fingerprint", "--list-keys", "--", uid) + for line := range strings.SplitSeq(out, "\n") { + if f := strings.Split(line, ":"); f[0] == "fpr" && len(f) > 9 { + return f[9] + } + } + r.t.Fatalf("no fingerprint for %q in:\n%s", uid, out) + return "" +} + +// listing is `gpg --with-colons --with-keygrip -K fpr`. +func (r *agentRig) listing(fpr string) string { + return r.must(r.agent, nil, "gpg", "--with-colons", "--with-keygrip", "-K", "--", fpr) +} + +// primaryGrip is the first grp: of the listing, which belongs to the primary. +func (r *agentRig) primaryGrip(fpr string) string { + for line := range strings.SplitSeq(r.listing(fpr), "\n") { + if f := strings.Split(line, ":"); f[0] == "grp" && len(f) > 9 { + return strings.Split(f[9], ",")[0] + } + } + r.t.Fatal("no primary grip") + return "" +} + +// subkeyGrips is internal/cli's parseKeygrips for a single key: grips of ssb +// records by capability, composite grips split. +func (r *agentRig) subkeyGrips(fpr string) (signers, encryptors []string) { + caps, inSub := "", false + for line := range strings.SplitSeq(r.listing(fpr), "\n") { + f := strings.Split(line, ":") + switch f[0] { + case "sec": + inSub = false + case "ssb": + inSub = true + caps = "" + if len(f) > 11 { + caps = f[11] + } + case "grp": + if !inSub || len(f) < 10 { + continue + } + if strings.Contains(caps, "s") { + signers = append(signers, strings.Split(f[9], ",")...) + } + if strings.Contains(caps, "e") { + encryptors = append(encryptors, strings.Split(f[9], ",")...) + } + } + } + return signers, encryptors +} + +// realAgentRoundTrip generates the pinned key (gen: each step a gpg argument +// list) and a second, unpinned one, pins the first through the proxy with the +// scope sign/decrypt asks for, and drives a client homedir against it. +func realAgentRoundTrip(t *testing.T, gen [][]string, sign, decrypt bool) { + r := newAgentRig(t) + dir := r.dir + + fpr, signers, encryptors := r.generate(gen) + primary := r.primaryGrip(fpr) + if sign && len(signers) == 0 { + t.Fatalf("control: the generated key has no signing subkey, so sign scope pins nothing: %s", r.listing(fpr)) + } + if decrypt && len(encryptors) == 0 { + t.Fatal("control: the generated key has no encryption subkey") + } + + // A second key the profile does NOT pin, with both a signing and an + // encryption subkey, so each negative below has something to be refused. + otherFpr, otherSigners, otherEncryptors := r.generate([][]string{ + {"--quick-gen-key", "Other ", "ed25519", "cert", "never"}, + {"--quick-add-key", "{fpr}", "ed25519", "sign", "never"}, + {"--quick-add-key", "{fpr}", "cv25519", "encr", "never"}, + }) + otherPrimary := r.primaryGrip(otherFpr) + + var pins []PinnedGrip + if sign { + for _, g := range signers { + pins = append(pins, PinnedGrip{Field: "identity.gpg.fingerprint", Grip: g, Scope: ScopeSign}) + } + } + if decrypt { + for _, g := range encryptors { + pins = append(pins, PinnedGrip{Field: "identity.gpg.fingerprint", Grip: g, Scope: ScopeDecrypt}) + } + } + + pub := r.must(r.agent, nil, "gpg", "--batch", "--export", fpr) + otherPub := r.must(r.agent, nil, "gpg", "--batch", "--export", otherFpr) + // --no-autostart: an agent started in the client home would hold the + // socket the proxy is about to bind. + r.must(r.client, []byte(pub), "gpg", "--batch", "--no-autostart", "--import") + r.must(r.client, []byte(otherPub), "gpg", "--batch", "--no-autostart", "--import") + + upstream := strings.TrimSpace(r.must(r.agent, nil, "gpgconf", "--list-dirs", "agent-socket")) + // Without /run/user/UID gpgconf falls back to the homedir and REFUSES + // --create-socketdir (measured, gpgconf 2.4.4 in ubuntu:24.04): the + // homedir is then the socket directory and already exists. + r.run(r.client, nil, "gpgconf", "--create-socketdir") + sock := strings.TrimSpace(r.must(r.client, nil, "gpgconf", "--list-dirs", "agent-socket")) + audit := &auditLog{} + p, err := New(pins, upstream, sock, audit.add) + if err != nil { + t.Fatal(err) + } + go p.Serve() + t.Cleanup(p.Close) + + client := []string{"gpg", "--batch", "--no-autostart", "--trust-model", "always"} + crun := func(args ...string) (string, string, error) { + return r.run(r.client, nil, append(append([]string{}, client...), args...)...) + } + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p + } + msg := write("msg", "hello through the proxy\n") + + // ── what the pin grants ───────────────────────────────────────────────── + if sign { + _, errs, err := crun("-u", fpr, "--detach-sign", msg) + if err != nil { + t.Fatalf("sign through the proxy: %v\n%s\naudit: %v", err, errs, audit.lines()) + } + if strings.Contains(errs, "older than us") { + t.Errorf("gpg was told a wrong agent version:\n%s", errs) + } + if _, errs, err := crun("--verify", msg+".sig", msg); err != nil { + t.Fatalf("verify: %v\n%s", err, errs) + } + } else { + // Positive control for the refusal: the agent itself signs with this key. + if _, errs, err := r.gpgAgent("-u", fpr, "--detach-sign", "-o", filepath.Join(dir, "ctl.sig"), msg); err != nil { + t.Fatalf("control: the agent cannot sign with the key either: %v\n%s", err, errs) + } + if _, _, err := crun("-u", fpr, "--detach-sign", "-o", filepath.Join(dir, "no.sig"), msg); err == nil { + t.Error("decrypt-only scope signed through the proxy") + } + if _, err := os.Stat(filepath.Join(dir, "no.sig")); err == nil { + t.Error("a signature file exists under a decrypt-only scope") + } + } + + toPinned := filepath.Join(dir, "to-pinned.gpg") + r.must(r.agent, nil, "gpg", "--batch", "--trust-model", "always", "-r", fpr, "-o", toPinned, "-e", msg) + if decrypt { + out, errs, err := crun("-d", toPinned) + if err != nil { + t.Fatalf("decrypt through the proxy: %v\n%s\naudit: %v", err, errs, audit.lines()) + } + if out != "hello through the proxy\n" { + t.Fatalf("decrypted %q", out) + } + } else { + if out, _, _ := r.run(r.agent, nil, "gpg", "--batch", "-d", toPinned); out != "hello through the proxy\n" { + t.Fatalf("control: the agent cannot decrypt the message either (got %q)", out) + } + if out, _, err := crun("-d", toPinned); err == nil || out != "" { + t.Errorf("sign-only scope decrypted through the proxy: err=%v out=%q", err, out) + } + } + + // ── what it must not grant ────────────────────────────────────────────── + // The other key: signing and decrypting both work in the agent home (the + // controls), and neither works through the proxy. + if _, errs, err := r.gpgAgent("-u", otherFpr, "--detach-sign", "-o", filepath.Join(dir, "other-ctl.sig"), msg); err != nil { + t.Fatalf("control: the agent cannot sign with the other key: %v\n%s", err, errs) + } + if _, _, err := crun("-u", otherFpr, "--detach-sign", "-o", filepath.Join(dir, "other.sig"), msg); err == nil { + t.Error("signed with a key the profile did not pin") + } + if _, err := os.Stat(filepath.Join(dir, "other.sig")); err == nil { + t.Error("a signature by the unpinned key exists") + } + toOther := filepath.Join(dir, "to-other.gpg") + r.must(r.agent, nil, "gpg", "--batch", "--trust-model", "always", "-r", otherFpr, "-o", toOther, "-e", msg) + if out, _, _ := r.run(r.agent, nil, "gpg", "--batch", "-d", toOther); out != "hello through the proxy\n" { + t.Fatalf("control: the agent cannot decrypt to the other key (got %q)", out) + } + if out, _, err := crun("-d", toOther); err == nil || out != "" { + t.Errorf("decrypted a message addressed to an unpinned key: err=%v out=%q", err, out) + } + + // The enumeration shows the pin and nothing else. + list := func(home string) string { + out, _, _ := r.run(home, nil, "gpg-connect-agent", "--no-autostart", "KEYINFO --list", "/bye") + return strings.ToUpper(out) + } + atAgent := list(r.agent) + for _, g := range append(append([]string{otherPrimary}, otherSigners...), otherEncryptors...) { + if !strings.Contains(atAgent, strings.ToUpper(g)) { + t.Fatalf("control: the agent's own KEYINFO --list does not show %s:\n%s", g, atAgent) + } + } + atProxy := list(r.client) + for _, g := range pins { + if !strings.Contains(atProxy, g.Grip) { + t.Errorf("the proxy's KEYINFO --list omits the pinned grip %s:\n%s", g.Grip, atProxy) + } + } + for _, g := range append(append([]string{otherPrimary, primary}, otherSigners...), otherEncryptors...) { + if strings.Contains(atProxy, strings.ToUpper(g)) { + t.Errorf("the proxy's KEYINFO --list shows %s, which is not pinned:\n%s", g, atProxy) + } + } + + // The primary certifies, so it is never pinned: adding a user ID to the + // pinned key needs the primary's signature and must fail. The control is + // the same call on the unpinned key in the agent home, which succeeds. + uids := func(f string) int { + n := 0 + for line := range strings.SplitSeq(r.must(r.agent, nil, "gpg", "--with-colons", "--list-keys", "--", f), "\n") { + if strings.HasPrefix(line, "uid:") { + n++ + } + } + return n + } + before := uids(fpr) + if _, errs, err := r.gpgAgent("--quick-add-uid", otherFpr, "Control "); err != nil { + t.Fatalf("control: the agent cannot add a user ID to the other key: %v\n%s", err, errs) + } + if _, _, err := crun("--quick-add-uid", fpr, "Evil "); err == nil { + t.Error("a user ID was added to the pinned key through the proxy") + } + if after := uids(fpr); after != before { + t.Errorf("the pinned key's user IDs changed through the proxy: %d -> %d", before, after) + } + if strings.Contains(r.must(r.agent, nil, "gpg", "--list-keys", "--", fpr), "evil@attacker.example") { + t.Error("the host key carries the sandbox's user ID") + } + + // Still the narrow proxy with a real client on the other side. The + // control is that the agent exports the secret key when asked directly, so + // an empty answer through the proxy is the proxy's refusal. + if ctl := r.must(r.agent, nil, "gpg", "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--export-secret-keys", fpr); ctl == "" { + t.Fatal("control: the agent exported no secret key when asked directly") + } + // No loopback flags here: gpg would send OPTION pinentry-mode, which the + // proxy refuses first, and the command would fail for that reason without + // ever reaching EXPORT_KEY. The audit assertion below is what proves it did. + exp, errs, err := crun("--export-secret-keys", fpr) + if exp != "" { + t.Errorf("--export-secret-keys through the proxy returned %d bytes", len(exp)) + } + if err == nil { + t.Errorf("--export-secret-keys through the proxy exited 0: %s", errs) + } + if !audit.contains("export private key material") { + t.Errorf("no export refusal in the audit, so the proxy may not have been asked: %v", audit.lines()) + } +} diff --git a/internal/gpgproxy/scope_test.go b/internal/gpgproxy/scope_test.go new file mode 100644 index 00000000..31a6acbf --- /dev/null +++ b/internal/gpgproxy/scope_test.go @@ -0,0 +1,795 @@ +package gpgproxy + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// pinnedSign and pinnedBoth are the two fixture grip sets the scope tests use. +func pinnedBoth() []PinnedGrip { + return []PinnedGrip{ + {Field: "identity.gpg.fingerprint", Grip: gripPrimary, Scope: ScopeSign | ScopeDecrypt}, + {Field: "identity.gpg.fingerprint", Grip: gripSubkey, Scope: ScopeDecrypt}, + } +} + +func pinnedSignOnly() []PinnedGrip { + return []PinnedGrip{ + {Field: "identity.gpg.fingerprint", Grip: gripPrimary, Scope: ScopeSign}, + } +} + +// pinnedDecryptAsCLI is what internal/cli pins for scope = ["decrypt"]: the +// primary grip carries the profile's scope too, not only the subkey. +func pinnedDecryptAsCLI() []PinnedGrip { + return []PinnedGrip{ + {Field: "identity.gpg.fingerprint", Grip: gripPrimary, Scope: ScopeDecrypt}, + {Field: "identity.gpg.fingerprint", Grip: gripSubkey, Scope: ScopeDecrypt}, + } +} + +func pinnedDecryptOnly() []PinnedGrip { + return []PinnedGrip{ + {Field: "identity.gpg.fingerprint", Grip: gripSubkey, Scope: ScopeDecrypt}, + } +} + +// ── construction ──────────────────────────────────────────────────────────── + +// TestEmptyPinSetRefuses: a proxy with nothing to pin advertises nothing and +// refuses every signature, which is a sandbox that looks configured and is not. +func TestEmptyPinSetRefuses(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, err := New(nil, a.path, filepath.Join(shortDir(t), "s.sock"), nil) + if err == nil { + t.Fatal("New accepted an empty pin set") + } + if !strings.Contains(err.Error(), "no keygrip to pin") { + t.Errorf("refusal does not name the cause: %v", err) + } +} + +// TestZeroScopeRefuses: a grip with an empty scope is a grant that grants +// nothing, and snug refuses that rather than accepting it. +func TestZeroScopeRefuses(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, err := New([]PinnedGrip{{Field: "identity.gpg.fingerprint", Grip: gripPrimary}}, + a.path, filepath.Join(shortDir(t), "s.sock"), nil) + if err == nil { + t.Fatal("New accepted a grip with no scope") + } + if !strings.Contains(err.Error(), "no scope") { + t.Errorf("refusal does not name the cause: %v", err) + } + if !strings.Contains(err.Error(), "identity.gpg.scope") { + t.Errorf("refusal does not name the fix: %v", err) + } +} + +// TestBadGripRefuses: a grip that is not 40 hex characters cannot be compared +// against the pin set and must be refused at construction. +func TestBadGripRefuses(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + for _, bad := range []string{"", "xyz", gripPrimary + "00", "zz" + gripPrimary[2:]} { + _, err := New([]PinnedGrip{{Field: "f", Grip: bad, Scope: ScopeSign}}, + a.path, filepath.Join(shortDir(t), "s.sock"), nil) + if err == nil { + t.Errorf("New accepted grip %q", bad) + } + } +} + +// TestUnheldGripRefusesTheRun: the startup probe asks the agent whether it +// holds the grip, and refuses the run when it does not. This is invariant 5 in +// its literal form. +func TestUnheldGripRefusesTheRun(t *testing.T) { + a := newFakeAgent(t, gripPrimary) // does NOT hold gripSubkey + _, err := New(pinnedBoth(), a.path, filepath.Join(shortDir(t), "s.sock"), nil) + if err == nil { + t.Fatal("New started with a grip the agent does not hold") + } + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(gripSubkey)) { + t.Errorf("refusal does not name the missing grip: %v", err) + } + if !strings.Contains(err.Error(), "remove identity.gpg") { + t.Errorf("refusal does not name the fix: %v", err) + } +} + +// TestUnreachableAgentRefuses: no agent means nothing to proxy. +func TestUnreachableAgentRefuses(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "nope.sock") + _, err := New([]PinnedGrip{{Field: "f", Grip: gripPrimary, Scope: ScopeSign}}, + missing, filepath.Join(dir, "s.sock"), nil) + if err == nil { + t.Fatal("New started against a dead agent") + } +} + +// TestEmptyUpstreamRefuses: an unset upstream is named, not dialled. +func TestEmptyUpstreamRefuses(t *testing.T) { + _, err := New([]PinnedGrip{{Field: "f", Grip: gripPrimary, Scope: ScopeSign}}, + "", filepath.Join(shortDir(t), "s.sock"), nil) + if err == nil { + t.Fatal("New started with no upstream") + } + if !strings.Contains(err.Error(), "no gpg-agent") { + t.Errorf("refusal does not name the cause: %v", err) + } +} + +// TestSignProbeDrivesPKSIGN: a sign-scoped grip is proven signable at startup. +// The probe must actually reach PKSIGN with the pinned grip, not merely HAVEKEY. +func TestSignProbeDrivesPKSIGN(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, _, _ = newTestProxy(t, a, pinnedSignOnly()) + got := a.sawSign() + if len(got) != 1 || got[0] != gripPrimary { + t.Fatalf("startup probe signs = %v, want exactly [%s]", got, gripPrimary) + } +} + +// TestDecryptGripIsNotSignProbed: a decrypt-only grip must NOT be sign-probed, +// because the agent would refuse SIGKEY on the encryption subkey and turn a +// working decryption setup into a refusal. +func TestDecryptGripIsNotSignProbed(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, _, _ = newTestProxy(t, a, pinnedDecryptOnly()) + if got := a.sawSign(); len(got) != 0 { + t.Fatalf("startup probe signed with a decrypt-only grip: %v", got) + } +} + +// TestUnsignableGripRefusesTheRun: a grip the agent LISTS but will not USE must +// refuse the run, because snug authors commit.gpgsign = true inside and a key +// that cannot sign fails every commit with an error naming no cause. +func TestUnsignableGripRefusesTheRun(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + a.setRefuseSign(strings.ToUpper(gripPrimary)) + _, err := New(pinnedSignOnly(), a.path, filepath.Join(shortDir(t), "s.sock"), nil) + if err == nil { + t.Fatal("New started with a grip the agent holds but will not sign with") + } + if !strings.Contains(err.Error(), "refused to sign") { + t.Errorf("refusal does not name the cause: %v", err) + } + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(gripPrimary)) { + t.Errorf("refusal does not name the grip: %v", err) + } +} + +// ── the local answers ─────────────────────────────────────────────────────── + +// TestHaveKeyListShowsOnlyPinned is THE leak test: the host agent holds a key +// the profile did not pin, and HAVEKEY --list must not mention it. +func TestHaveKeyListShowsOnlyPinned(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripSubkey, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedBoth()) + c := dial(t, sock) + reply := c.send("HAVEKEY --list=1000") + // The D line carries RAW 20-byte grips, percent-escaped, so unescape first. + payload := "" + if p, ok := strings.CutPrefix(reply[0], "D "); ok { + payload = string(assuanUnescape(p)) + } + wantPrimary, _ := parseGrip("f", gripPrimary) + if !strings.Contains(payload, string(wantPrimary[:])) { + t.Fatalf("HAVEKEY --list omitted the pinned primary grip: %v", reply) + } + wantOther, _ := parseGrip("f", gripOther) + if strings.Contains(payload, string(wantOther[:])) { + t.Fatalf("HAVEKEY --list leaked an unpinned grip: %v", reply) + } +} + +// TestHaveKeySingleUnpinnedRefused: the single-grip form must read an unpinned +// grip as absent, not forward the question to the agent. +func TestHaveKeySingleUnpinnedRefused(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("HAVEKEY " + gripOther) + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("HAVEKEY for an unpinned grip answered %v, want ERR", reply) + } + // The agent holds gripOther, so a FORWARDED question would answer OK. The + // proxy must answer from its own pin set and never ask. + if a.contactCount() != before { + t.Errorf("the proxy contacted the agent for a local HAVEKEY answer") + } +} + +// TestHaveKeySinglePinnedAnswersOK. +func TestHaveKeySinglePinnedAnswersOK(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + reply := c.send("HAVEKEY " + gripPrimary) + if !strings.HasPrefix(terminalOf(reply), "OK") { + t.Fatalf("HAVEKEY for the pinned grip answered %v, want OK", reply) + } +} + +// TestKeyInfoListShowsOnlyPinned. +func TestKeyInfoListShowsOnlyPinned(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripSubkey, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedBoth()) + c := dial(t, sock) + reply := c.send("KEYINFO --list") + joined := strings.Join(reply, "\n") + if strings.Contains(joined, gripOther) { + t.Fatalf("KEYINFO --list leaked an unpinned grip: %v", reply) + } +} + +// TestGetInfoVersionIsTheAgents: gpg picks its protocol from the agent's +// version, so the proxy forwards the question rather than answering a fixed +// string. A fixed 2.4.8 made gpg 2.5 warn "server 'gpg-agent' is older than us" +// on every call. +func TestGetInfoVersionIsTheAgents(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("GETINFO version") + if !strings.Contains(strings.Join(reply, "\n"), "D 2.4.8") { + t.Fatalf("GETINFO version answered %v, want the agent's D 2.4.8", reply) + } + if a.contactCount() == before { + t.Errorf("GETINFO version was answered locally, not by the agent") + } +} + +// TestGetInfoHostStateIsForbidden: the GETINFO forms that describe the host +// agent — its pid, its socket paths, its environment — are refused before the +// agent is contacted. Measured on gpg-agent 2.5.24, std_session_env answers +// GPG_TTY, DISPLAY and XAUTHORITY, and getenv answers any variable asked for. +func TestGetInfoHostStateIsForbidden(t *testing.T) { + for _, what := range []string{"pid", "socket_name", "ssh_socket_name", + "std_session_env", "std_startup_env", "getenv HOME", "connections", "nonsense"} { + t.Run(what, func(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, audit := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("GETINFO " + what) + if got := terminalOf(reply); got != errForbidden { + t.Fatalf("GETINFO %s answered %v, want %q", what, reply, errForbidden) + } + if a.contactCount() != before { + t.Errorf("GETINFO %s reached the agent", what) + } + if !audit.contains("GETINFO") { + t.Errorf("GETINFO %s refused without an audit line: %v", what, audit.lines()) + } + }) + } +} + +// TestRefusalIsForbiddenNotErrZero: a refusal carries the agent's own +// GPG_ERR_FORBIDDEN. `ERR 0` is GPG_ERR_NO_ERROR to libgpg-error. +func TestRefusalIsForbiddenNotErrZero(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + for _, cmd := range []string{"EXPORT_KEY " + gripPrimary, "FOOBAR"} { + if got := terminalOf(c.send(cmd)); got != errForbidden { + t.Errorf("%s answered %q, want %q", cmd, got, errForbidden) + } + } +} + +// TestGetInfoSocketNameIsNotForwarded: the host agent's socket path must not +// leak, or a client could try to reach it directly. +func TestGetInfoSocketNameIsNotForwarded(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + reply := c.send("GETINFO socket_name") + joined := strings.Join(reply, "\n") + if strings.Contains(joined, a.path) { + t.Fatalf("GETINFO socket_name leaked the host agent path: %v", reply) + } +} + +// ── the forwarded path ────────────────────────────────────────────────────── + +// TestSignRoundTrip: SIGKEY, SETKEYDESC, SETHASH, PKSIGN all reach the agent on +// ONE connection, and the signature comes back. +func TestSignRoundTrip(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + for _, cmd := range []string{ + "RESET", + "SIGKEY " + gripPrimary, + "SETKEYDESC snug+test", + "SETHASH 10 " + strings.Repeat("ab", 64), + } { + if got := terminalOf(c.send(cmd)); !strings.HasPrefix(got, "OK") { + t.Fatalf("%s answered %q, want OK", cmd, got) + } + } + reply := c.send("PKSIGN") + if !strings.Contains(strings.Join(reply, "\n"), "sig-val") { + t.Fatalf("PKSIGN reply carries no signature: %v", reply) + } + // State survived: the SIGKEY set on this connection is what PKSIGN used. + // + // The probe signed too, so the SIGN list holds the probe's call and then + // this one. + got := a.sawSign() + if len(got) != 2 || got[1] != gripPrimary { + t.Fatalf("signs = %v, want the probe and one client sign with %s", got, gripPrimary) + } +} + +// TestSignUnpinnedGripRefused: SIGKEY for a grip the profile did not pin is +// refused WITHOUT the agent being contacted. +func TestSignUnpinnedGripRefused(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripOther) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("SIGKEY " + gripOther) + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("SIGKEY for an unpinned grip answered %v, want ERR", reply) + } + if a.contactCount() != before { + t.Errorf("the proxy contacted the agent for an unpinned SIGKEY") + } + if !log.contains("not in the pinned set") { + t.Errorf("refusal was not audited: %v", log.lines()) + } +} + +// TestSetKeyRefusedWithoutDecryptScope: with only sign granted, SETKEY must be +// refused — a signing grant must not double as a decryption oracle. The grip +// IS pinned (for signing), so the refusal is the scope check and not the +// membership check. +func TestSetKeyRefusedWithoutDecryptScope(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("SETKEY " + gripPrimary) + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("SETKEY under a sign-only scope answered %v, want ERR", reply) + } + if a.contactCount() != before { + t.Errorf("the proxy forwarded SETKEY under a sign-only scope") + } + if !log.contains("without the decrypt scope") { + t.Errorf("refusal did not name the scope, only audit: %v", log.lines()) + } +} + +// TestSignKeyRefusedWithoutSignScope: with only decrypt granted, SIGKEY must be +// refused — a decryption grant must not double as a signing oracle. +func TestSignKeyRefusedWithoutSignScope(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("SIGKEY " + gripSubkey) + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("SIGKEY under a decrypt-only scope answered %v, want ERR", reply) + } + if a.contactCount() != before { + t.Errorf("the proxy forwarded SIGKEY under a decrypt-only scope") + } +} + +// TestDecryptRoundTrip: the full measured decryption dialogue, INCLUDING the +// server-driven INQUIRE. This is the test that would deadlock a naive proxy. +func TestDecryptRoundTrip(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + if got := terminalOf(c.send("RESET")); !strings.HasPrefix(got, "OK") { + t.Fatalf("RESET: %q", got) + } + if got := terminalOf(c.send("SETKEY " + gripSubkey)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY: %q", got) + } + // PKDECRYPT: the client must answer the INQUIRE. send() alone would only + // read until OK/ERR and never see the INQUIRE; answerInquire drives it. + if err := writeLine(c.w, "PKDECRYPT"); err != nil { + t.Fatal(err) + } + if err := c.w.Flush(); err != nil { + t.Fatal(err) + } + reply := c.answerInquire("(7:enc-val(4:ecdh(1:s4:abcd)(1:e4:efgh)))") + joined := strings.Join(reply, "\n") + if !strings.Contains(joined, "INQUIRE CIPHERTEXT") { + t.Fatalf("the INQUIRE was not relayed: %v", reply) + } + if !strings.Contains(joined, "value33") { + t.Fatalf("PKDECRYPT reply carries no plaintext: %v", reply) + } + if got := a.sawDecrypt(); len(got) != 1 || got[0] != gripSubkey { + t.Fatalf("decrypts = %v, want one with %s", got, gripSubkey) + } +} + +// TestPkDecryptKemOptionReachesAgent: gpg 2.5 sends `PKDECRYPT --kem=PGP` and +// expects the agent to finish the ECDH derivation; forwarding a bare PKDECRYPT +// made every 2.5 decryption fail with `Wrong secret key used`. Both the 2.4 +// and the 2.5 form must arrive exactly as sent. +func TestPkDecryptKemOptionReachesAgent(t *testing.T) { + for _, cmd := range []string{"PKDECRYPT", "PKDECRYPT --kem=PGP", "PKDECRYPT --kem=PQC-PGP"} { + t.Run(cmd, func(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + if got := terminalOf(c.send("SETKEY " + gripSubkey)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY: %q", got) + } + if err := writeLine(c.w, cmd); err != nil { + t.Fatal(err) + } + if err := c.w.Flush(); err != nil { + t.Fatal(err) + } + reply := c.answerInquire("(7:enc-val(4:ecdh(1:s4:abcd)(1:e4:efgh)))") + if !strings.Contains(strings.Join(reply, "\n"), "value33") { + t.Fatalf("%s reply carries no plaintext: %v", cmd, reply) + } + if got := a.sawPkdecryptLines(); len(got) != 1 || got[0] != cmd { + t.Fatalf("agent saw %q, want [%q]", got, cmd) + } + }) + } +} + +// TestSetKeyAnotherIsGripChecked: a composite (Kyber) subkey's second grip +// arrives as `SETKEY --another `. A pinned decrypt grip is forwarded with +// the option intact; an unpinned one is refused like a plain SETKEY. +func TestSetKeyAnotherIsGripChecked(t *testing.T) { + a := newFakeAgent(t, gripSubkey, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + if got := terminalOf(c.send("SETKEY " + gripSubkey)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY : %q", got) + } + if got := terminalOf(c.send("SETKEY --another " + gripSubkey)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY --another : %q", got) + } + before := a.contactCount() + c2 := dial(t, sock) + if got := terminalOf(c2.send("SETKEY --another " + gripOther)); !strings.HasPrefix(got, "ERR") { + t.Fatalf("SETKEY --another answered %q, want ERR", got) + } + if a.contactCount() != before { + t.Errorf("SETKEY --another reached the agent") + } +} + +// TestPkDecryptUnknownOptionRefused: gpg-agent 2.5.24 ignores an option it does +// not know, so the proxy forwards only the forms gpg sends and refuses the rest +// before the agent sees them. +func TestPkDecryptUnknownOptionRefused(t *testing.T) { + for _, cmd := range []string{"PKDECRYPT --bogus", "PKDECRYPT --kem=CMS", "PKDECRYPT --kem"} { + t.Run(cmd, func(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + if got := terminalOf(c.send("SETKEY " + gripSubkey)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY: %q", got) + } + if got := terminalOf(c.send(cmd)); got != errForbidden { + t.Fatalf("%s answered %q, want %q", cmd, got, errForbidden) + } + if got := a.sawPkdecryptLines(); len(got) != 0 { + t.Fatalf("agent saw %q", got) + } + }) + } +} + +// TestPkDecryptWithoutSetKeyRefused: PKDECRYPT before any SETKEY is the agent's +// own `No secret key`, forwarded rather than invented. +func TestPkDecryptWithoutSetKeyRefused(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + reply := c.send("PKDECRYPT") + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("PKDECRYPT without SETKEY answered %v, want ERR", reply) + } +} + +// TestPkDecryptRefusedWithoutScope: with only sign granted, PKDECRYPT reaches +// the agent but SETKEY was refused, so there is no selected key — the agent +// answers ERR. The proxy must relay that, not invent an OK. +func TestPkDecryptRefusedWithoutScope(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + reply := c.send("PKDECRYPT") + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("PKDECRYPT under a sign-only scope answered %v, want ERR", reply) + } + if got := a.sawDecrypt(); len(got) != 0 { + t.Fatalf("the agent decrypted under a sign-only scope: %v", got) + } +} + +// ── the refusals that are the point ───────────────────────────────────────── + +// TestExportKeyRefused is THE test for the verb that transfers private key +// material. It must be refused, and the agent must never see it. +func TestExportKeyRefused(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripSubkey) + _, sock, log := newTestProxy(t, a, pinnedBoth()) + c := dial(t, sock) + // `gpg --export-secret-keys` drives exactly this trio. + c.send("KEYWRAP_KEY --export") + c.send("KEYINFO " + gripPrimary) + reply := c.send("EXPORT_KEY " + gripPrimary) // TWO spaces, as measured + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("EXPORT_KEY answered %v, want ERR", reply) + } + if got := a.sawExport(); len(got) != 0 { + t.Fatalf("the agent was asked to export: %v", got) + } + if !log.contains("export private key material") { + t.Errorf("the export refusal was not audited: %v", log.lines()) + } +} + +// TestMutationVerbsRefused: every verb that changes or controls the agent. +func TestMutationVerbsRefused(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripSubkey) + _, sock, log := newTestProxy(t, a, pinnedBoth()) + c := dial(t, sock) + for _, cmd := range []string{ + "DELETEKEY " + gripPrimary, + "IMPORTKEYS", + "PASSPHRASE abc", + "PRESET_PASSPHRASE " + gripPrimary, + "CLEARPASSPHRASE " + gripPrimary, + "SETEVENTS", + "SETINFO x y", + "MKKEY foo", + "KEYTOOL", + // SCD is special: SERIALNO is part of gpg's normal path and MUST be + // forwarded, while every other SCD verb drives the card and is refused. + // See TestScdSerialnoForwardedButOtherScdRefused. + "SCD LEARN", + "SCD APDU 00", + } { + reply := c.send(cmd) + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Errorf("%s answered %v, want ERR", cmd, reply) + } + } + if got := a.sawExport(); len(got) != 0 { + t.Fatalf("a mutation verb reached the agent: %v", got) + } + if len(log.lines()) == 0 { + t.Errorf("no refusal was audited") + } +} + +// TestScdSerialnoForwardedButOtherScdRefused is the regression test for a live +// bug: refusing SCD wholesale broke EVERY sign and decrypt with +// `problem with fast path key listing: Invalid IPC response - ignored`, +// because gpg sends `SCD SERIALNO` on its normal path. Only the read-only query +// may pass; the verbs that drive the card must not. +func TestScdSerialnoForwardedButOtherScdRefused(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + + before := a.contactCount() + if got := terminalOf(c.send("SCD SERIALNO")); !strings.HasPrefix(got, "ERR") { + // The fake agent answers ERR for SCD SERIALNO, exactly as a host agent + // without a smartcard does. What matters is that it REACHED the agent. + t.Fatalf("SCD SERIALNO answered %q, want the agent's ERR", got) + } + if a.contactCount() == before { + t.Errorf("SCD SERIALNO was answered locally; gpg needs it forwarded") + } + if log.contains("smartcard") { + t.Errorf("SCD SERIALNO was refused: %v", log.lines()) + } + + for _, cmd := range []string{"SCD LEARN", "SCD APDU 00", "SCD SERIALNO extra"} { + before := a.contactCount() + if got := terminalOf(c.send(cmd)); !strings.HasPrefix(got, "ERR") { + t.Errorf("%s answered %q, want ERR", cmd, got) + } + if a.contactCount() != before { + t.Errorf("%s was forwarded to the agent", cmd) + } + if !log.contains("smartcard") { + t.Errorf("%s was not audited as a smartcard refusal: %v", cmd, log.lines()) + } + } + // What the agent saw: the one read-only query, and none of the verbs that + // drive the card. + saw := a.sawScd() + if len(saw) == 0 { + t.Error("control: the agent recorded no SCD at all, so the check below proves nothing") + } + for _, got := range saw { + if strings.TrimSpace(got) != "SCD SERIALNO" { + t.Errorf("the agent received %q", got) + } + } +} + +// TestUnknownVerbRefused: an unrecognised command is refused, never guessed at. +func TestUnknownVerbRefused(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + reply := c.send("WOMBAT frobnicate") + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("an unknown verb answered %v, want ERR", reply) + } + if a.contactCount() != before { + t.Errorf("an unknown verb was forwarded to the agent") + } + if !log.contains("unknown gpg-agent command") { + t.Errorf("the unknown-verb refusal was not audited: %v", log.lines()) + } +} + +// TestByeClosesConnection: BYE is answered locally. +func TestByeClosesConnection(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + reply := c.send("BYE") + if !strings.HasPrefix(terminalOf(reply), "OK") { + t.Fatalf("BYE answered %v, want OK", reply) + } + // The connection is done; the next read must not hang for a reply. + c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if _, err := readLine(c.r); err == nil { + t.Errorf("the proxy kept the connection open after BYE") + } +} + +// ── construction helpers for the fixtures ─────────────────────────────────── + +func TestGripParsingRoundTrips(t *testing.T) { + raw, err := parseGrip("f", gripPrimary) + if err != nil { + t.Fatal(err) + } + if hexString(raw[:]) != strings.ToLower(gripPrimary) { + t.Fatalf("round trip: %s", hexString(raw[:])) + } + if _, err := parseGrip("f", strings.ToUpper(gripPrimary)); err != nil { + t.Errorf("uppercase grip refused: %v", err) + } +} + +func TestScopeStrings(t *testing.T) { + cases := map[Scope]string{ + 0: "nothing", + ScopeSign: "sign", + ScopeDecrypt: "decrypt", + ScopeSign | ScopeDecrypt: "sign+decrypt", + } + for s, want := range cases { + if got := s.String(); got != want { + t.Errorf("Scope(%d).String() = %q, want %q", s, got, want) + } + } +} + +// TestSocketIsRestricted: anything that can connect can ask for a signature. +func TestSocketIsRestricted(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + fi, err := os.Stat(sock) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("proxy socket mode is %o, want 0600", fi.Mode().Perm()) + } +} + +// TestUpstreamUnreachableMidSession: a dead agent mid-connection is reported, +// not hung on. +func TestUpstreamUnreachableMidSession(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + p, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + // Kill the agent's listener so the dial fails. + a.ln.Close() + p.upstream = filepath.Join(t.TempDir(), "gone.sock") + c := dial(t, sock) + reply := c.send("RESET") + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("a dead upstream answered %v, want ERR", reply) + } +} + +// ── scope crossing through the agent's one selected key ───────────────────── + +// TestDecryptScopeIsNotASigningOracle: gpg-agent keeps one selected key for +// SIGKEY and SETKEY. Measured on 2.5.24 through the proxy under a decrypt-only +// profile, `SETKEY `, SETHASH, PKSIGN returned a signature. +func TestDecryptScopeIsNotASigningOracle(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptAsCLI()) + c := dial(t, sock) + if got := terminalOf(c.send("SETKEY " + gripPrimary)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY : %q", got) + } + c.send("SETHASH 10 " + strings.Repeat("ab", 64)) + if got := terminalOf(c.send("PKSIGN")); got != errForbidden { + t.Fatalf("PKSIGN after SETKEY answered %q, want %q", got, errForbidden) + } + if got := a.sawSign(); len(got) != 0 { + t.Fatalf("the agent signed under a decrypt-only scope: %v", got) + } +} + +// TestSignSelectionIsNotADecryption: the mirror case, and `SETKEY --another` +// after a SIGKEY, which would add a decrypt grip to a sign selection. +func TestSignSelectionIsNotADecryption(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedBoth()) + c := dial(t, sock) + if got := terminalOf(c.send("SIGKEY " + gripPrimary)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SIGKEY: %q", got) + } + if got := terminalOf(c.send("SETKEY --another " + gripSubkey)); !strings.HasPrefix(got, "ERR") { + t.Fatalf("SETKEY --another after SIGKEY answered %q, want ERR", got) + } + if got := terminalOf(c.send("PKDECRYPT")); got != errForbidden { + t.Fatalf("PKDECRYPT after SIGKEY answered %q, want %q", got, errForbidden) + } + if got := a.sawDecrypt(); len(got) != 0 { + t.Fatalf("the agent decrypted after a SIGKEY: %v", got) + } +} + +// TestRefusedSelectionClearsTheSession: SIGKEY succeeds, a +// later refused SETKEY leaves the AGENT holding the sign grip, and the +// following PKSIGN must not use it — nor may a RESET-cleared one. +func TestRefusedSelectionClearsTheSession(t *testing.T) { + for _, between := range []string{"SETKEY " + gripOther, "RESET"} { + t.Run(between, func(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + if got := terminalOf(c.send("SIGKEY " + gripPrimary)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SIGKEY: %q", got) + } + c.send(between) + if got := terminalOf(c.send("PKSIGN")); got != errForbidden { + t.Fatalf("PKSIGN after %s answered %q, want %q", between, got, errForbidden) + } + }) + } +} + +// TestGetInfoTrailingArgumentRefused: the zero-argument GETINFO forms are +// forwarded as the bare name, and one carrying a trailing word is refused +// before the agent sees it (red team: `GETINFO version EVIL` was forwarded). +func TestGetInfoTrailingArgumentRefused(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + before := a.contactCount() + if got := terminalOf(c.send("GETINFO version EVIL")); got != errForbidden { + t.Fatalf("GETINFO version EVIL answered %q, want %q", got, errForbidden) + } + if a.contactCount() != before { + t.Errorf("GETINFO version EVIL reached the agent") + } +} diff --git a/internal/gpgproxy/session_test.go b/internal/gpgproxy/session_test.go new file mode 100644 index 00000000..d5c2293a --- /dev/null +++ b/internal/gpgproxy/session_test.go @@ -0,0 +1,547 @@ +package gpgproxy + +import ( + "bufio" + "errors" + "net" + "os" + "strings" + "testing" + "time" +) + +// This file holds the tests for what the proxy does between the sandbox and +// the agent that is NOT a plain forward: the INQUIRE relay, OPTION, SETKEYDESC, +// the reply bound and teardown. scope_test.go holds the verbs and the scopes. + +// write sends one line without waiting for a reply. +func (c *client) write(line string) { + c.t.Helper() + if err := writeLine(c.w, line); err != nil { + c.t.Fatal(err) + } + if err := c.w.Flush(); err != nil { + c.t.Fatal(err) + } +} + +// drain reads until the connection ends and returns every line it saw. It fails +// the test if the connection is still open when the deadline passes, because +// "the session ended" is the property under test and a timeout is its absence. +func (c *client) drain() []string { + c.t.Helper() + c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var out []string + for { + line, err := readLine(c.r) + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + c.t.Fatalf("the proxy kept the connection open; saw %q", out) + } + return out + } + out = append(out, line) + } +} + +// startDecrypt selects the pinned decrypt grip, sends PKDECRYPT and reads up to +// and including `INQUIRE CIPHERTEXT`, so the caller holds a client the agent is +// waiting on. The INQUIRE arriving is the positive control for every test built +// on it: without it, "the bad line was not forwarded" would be equally true of +// a dialogue that never reached the inquiry. +func startDecrypt(t *testing.T, a *fakeAgent) (*Proxy, *client, *auditLog) { + t.Helper() + p, sock, log := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + if got := terminalOf(c.send("SETKEY " + gripSubkey)); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEY: %q", got) + } + c.write("PKDECRYPT") + c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + line, err := readLine(c.r) + if err != nil { + t.Fatalf("waiting for the INQUIRE: %v", err) + } + if line == "INQUIRE CIPHERTEXT" { + c.conn.SetReadDeadline(time.Time{}) + return p, c, log + } + if isTerminal(line) { + t.Fatalf("PKDECRYPT ended without an INQUIRE: %q", line) + } + } +} + +// directKeyInfo asks the fake agent itself, bypassing the proxy, which grips it +// lists. It is the positive control for every leak check below: the unpinned +// grip IS visible to anything that can reach the agent, so its absence from +// what the sandbox sees is the proxy's doing and not an empty agent's. +func directKeyInfo(t *testing.T, a *fakeAgent) string { + t.Helper() + conn, err := net.Dial("unix", a.path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + cl := &client{t: t, conn: conn} + cl.r = bufio.NewReaderSize(conn, 4096) + cl.w = bufio.NewWriter(conn) + if _, err := readLine(cl.r); err != nil { + t.Fatal(err) + } + return strings.Join(cl.send("KEYINFO --list"), "\n") +} + +// TestInquireInjectionEndsTheSession: after `INQUIRE CIPHERTEXT` the sandbox +// answers with command lines instead of data. Measured on gpg-agent 2.5.24, any +// line that is not D/END/CAN aborts the inquiry and every line after it runs +// as an ordinary command, so `PKDECRYPT`, `X`, `KEYINFO --list`, `END` came +// back with the agent's unpinned keygrips as the answer to the client's NEXT +// command. It would catch a proxy that forwards the inquiry answer verbatim. +// +// The positive control is directKeyInfo: the agent does list gripOther. What it +// does not cover is the real agent's parsing; the fake agent only records what +// it was sent. +func TestInquireInjectionEndsTheSession(t *testing.T) { + attacks := map[string][]string{ + "the measured sequence": {"PKDECRYPT", "X", "KEYINFO --list", "GETINFO socket_name", "END", "RESET"}, + "data then a command": {"D abc", "KEYINFO --list", "GETINFO socket_name", "END"}, + "option after inquire": {"OPTION pinentry-mode=loopback", "KEYINFO --list", "END"}, + } + for name, lines := range attacks { + t.Run(name, func(t *testing.T) { + a := newFakeAgent(t, gripSubkey, gripOther) + if !strings.Contains(directKeyInfo(t, a), gripOther) { + t.Fatal("control: the agent does not list the unpinned grip, so no leak could show") + } + _, c, log := startDecrypt(t, a) + for _, l := range lines { + // The proxy may close mid-way; a write error is the session ending. + if writeLine(c.w, l) != nil { + break + } + } + c.w.Flush() + seen := c.drain() + joined := strings.Join(seen, "\n") + if !strings.Contains(joined, errForbidden) { + t.Errorf("the client was not told it was refused: %q", seen) + } + if strings.Contains(joined, gripOther) || strings.Contains(joined, a.path) || + strings.Contains(joined, "KEYINFO") { + t.Errorf("the injected commands were answered by the agent: %q", seen) + } + // What the agent saw of the answer is a CAN from the proxy, never + // the sandbox's command lines. + for _, got := range a.waitDecryptInquire(t) { + if got != "CAN" && got != "D abc" { + t.Errorf("the agent received %q from an inquiry answer", got) + } + } + if !log.contains("inquire answer") { + t.Errorf("the abort was not audited: %v", log.lines()) + } + }) + } +} + +// TestInquireAnswerTerminatorsAreExact: END, CAN and CANCEL end the answer when +// they are the whole line. A prefix test lets `CANCELLED` or `END x` through to +// an agent that may read them as something else, and a missing CAN left the +// proxy waiting while the client waited for the agent. +func TestInquireAnswerTerminatorsAreExact(t *testing.T) { + for _, bad := range []string{"ENDX", "END ", "CANCELLED", "CAN x", "end", "cancel", "", + "D", "d abc", "# comment", "KEYINFO --list"} { + t.Run("bad "+bad, func(t *testing.T) { + a := newFakeAgent(t, gripSubkey, gripOther) + _, c, _ := startDecrypt(t, a) + c.write(bad) + seen := c.drain() + if !strings.Contains(strings.Join(seen, "\n"), errForbidden) { + t.Errorf("%q in an inquire answer was not refused: %q", bad, seen) + } + got := a.waitDecryptInquire(t) + if len(got) != 1 || got[0] != "CAN" { + t.Errorf("the agent saw %q for %q, want exactly the proxy's [CAN]", got, bad) + } + }) + } + for _, good := range []string{"CAN", "CANCEL"} { + t.Run("good "+good, func(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, c, _ := startDecrypt(t, a) + c.write(good) + conn := c.conn + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var term string + for { + line, err := readLine(c.r) + if err != nil { + t.Fatalf("after %s: %v", good, err) + } + if isTerminal(line) { + term = line + break + } + } + if !strings.HasPrefix(term, "ERR") { + t.Errorf("a cancelled PKDECRYPT answered %q, want the agent's ERR", term) + } + if got := a.sawDecryptInquire(); len(got) != 1 || got[0] != good { + t.Errorf("the agent saw %q, want [%s] verbatim", got, good) + } + // A cancel is the client's decision, not an attack: the session lives. + conn.SetReadDeadline(time.Time{}) + if got := terminalOf(c.send("RESET")); !strings.HasPrefix(got, "OK") { + t.Errorf("RESET after %s answered %q, want OK", good, got) + } + }) + } +} + +// TestOptionIsAnsweredLocallyAndNeverForwarded: OPTION sets the environment +// the host agent hands its pinentry. Measured through the proxy before it +// answered locally, `ttyname=` made pinentry-curses open that path +// on the HOST and overwrite it, and `pinentry-mode=loopback` turned the +// passphrase prompt into an INQUIRE the sandbox answers. It would catch any +// OPTION reaching the agent, including one sent after the upstream is open. +func TestOptionIsAnsweredLocallyAndNeverForwarded(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + allowed := []string{"ttyname=/dev/pts/9", "ttytype=xterm", "display=:0", "xauthority=/x", + "putenv=FOO=bar", "lc-ctype=C", "lc-messages=C", "allow-pinentry-notify", + "agent-awareness=2.1.0", "TTYNAME=/dev/pts/9", "ttyname /dev/pts/9"} + refused := []string{"pinentry-mode=loopback", "pinentry-mode loopback", "debug=1", + "no-grab", "ttyname2=x", "putenv-x=1", "", "pinentry-program=/bin/sh"} + + for _, opened := range []bool{false, true} { + c := dial(t, sock) + if opened { + // RESET opens the upstream connection, so an OPTION after it could + // be written onto a live agent session. + c.send("RESET") + } + before := a.sawOptions() + if len(before) == 0 { + t.Fatal("control: the startup probe sent no OPTION, so a recorder that works would look the same") + } + for _, o := range allowed { + if got := terminalOf(c.send("OPTION " + o)); !strings.HasPrefix(got, "OK") { + t.Errorf("OPTION %s answered %q, want OK", o, got) + } + } + for _, o := range refused { + cmd := strings.TrimSpace("OPTION " + o) + if got := terminalOf(c.send(cmd)); got != errForbidden { + t.Errorf("%s answered %q, want %q", cmd, got, errForbidden) + } + } + if after := a.sawOptions(); len(after) != len(before) { + t.Errorf("upstream open=%v: OPTION reached the agent: %q", opened, after[len(before):]) + } + } + if !log.contains("OPTION") { + t.Errorf("an OPTION refusal was not audited: %v", log.lines()) + } +} + +// TestSetKeyDescIsTheProxysOwn: the pinentry prompt text is the proxy's. A +// sandbox-authored SETKEYDESC ("system update needs your password") reached the +// host pinentry verbatim before, which is a phishing line aimed at the human +// holding the passphrase. It would catch the client's text reaching the agent, +// and the proxy's own text going missing. +func TestSetKeyDescIsTheProxysOwn(t *testing.T) { + cases := []struct { + name, verb, grip, word string + pins []PinnedGrip + holds string + }{ + {"sign", "SIGKEY", gripPrimary, "sign", pinnedSignOnly(), gripPrimary}, + {"decrypt", "SETKEY", gripSubkey, "decrypt", pinnedDecryptOnly(), gripSubkey}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := newFakeAgent(t, tc.holds) + _, sock, _ := newTestProxy(t, a, tc.pins) + c := dial(t, sock) + before := len(a.sawSetKeyDesc()) + + // Before any selection the client's text is answered and dropped. + if got := terminalOf(c.send("SETKEYDESC evil+first")); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEYDESC answered %q, want OK", got) + } + if got := terminalOf(c.send(tc.verb + " " + tc.grip)); !strings.HasPrefix(got, "OK") { + t.Fatalf("%s: %q", tc.verb, got) + } + if got := terminalOf(c.send("SETKEYDESC system+update+needs+your+password")); !strings.HasPrefix(got, "OK") { + t.Fatalf("SETKEYDESC answered %q, want OK", got) + } + // The proxy's own SETKEYDESC reply was consumed upstream. If it + // leaked, this reply would be a stale OK instead of the version. + if reply := c.send("GETINFO version"); !strings.Contains(strings.Join(reply, "\n"), "D 2.4.8") { + t.Fatalf("the reply stream is out of step after SETKEYDESC: %q", reply) + } + + descs := a.sawSetKeyDesc()[before:] + if len(descs) != 1 { + t.Fatalf("the agent saw %d SETKEYDESC lines, want exactly the proxy's one: %q", len(descs), descs) + } + if strings.Contains(strings.Join(descs, "\n"), "evil") || + strings.Contains(strings.Join(descs, "\n"), "password") { + t.Errorf("the sandbox's SETKEYDESC text reached the agent: %q", descs) + } + want := "SETKEYDESC A+process+in+a+snug+sandbox+asks+to+" + tc.word + if !strings.HasPrefix(descs[0], want) || !strings.Contains(descs[0], strings.ToUpper(tc.grip)) { + t.Errorf("the proxy's SETKEYDESC = %q, want prefix %q naming the grip", descs[0], want) + } + }) + } + + t.Run("a refused selection sends none", func(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := len(a.sawSetKeyDesc()) + c.send("SIGKEY " + gripOther) + c.send("SETKEYDESC x") + if got := a.sawSetKeyDesc(); len(got) != before { + t.Errorf("SETKEYDESC reached the agent after a refused SIGKEY: %q", got[before:]) + } + }) + t.Run("--another adds no second prompt", func(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + before := len(a.sawSetKeyDesc()) + c.send("SETKEY " + gripSubkey) + c.send("SETKEY --another " + gripSubkey) + if got := a.sawSetKeyDesc()[before:]; len(got) != 1 { + t.Errorf("composite key sent %d SETKEYDESC lines, want one: %q", len(got), got) + } + }) +} + +// TestAgentInquiriesAreAnsweredUpstream: the agent asks the client questions +// while it works. Only the ciphertext during PKDECRYPT belongs to the sandbox. +// PINENTRY_LAUNCHED names the host pinentry's pid and tty, so it is answered +// END here and never shown; anything else (a PASSPHRASE prompt, a CIPHERTEXT +// outside a decryption) is cancelled. It would catch an INQUIRE reaching the +// sandbox, or an unanswered one stalling the agent. +func TestAgentInquiriesAreAnsweredUpstream(t *testing.T) { + cases := []struct { + keyword string + answer string + signs bool + }{ + {"PINENTRY_LAUNCHED 4242 curses 1.3.1 /dev/pts/9 - - 1000", "END", true}, + {"PASSPHRASE", "CAN", false}, + {"CIPHERTEXT", "CAN", false}, // the decrypt inquiry, asked outside PKDECRYPT + } + for _, tc := range cases { + t.Run(tc.keyword, func(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + a.setPreInquire(tc.keyword) // after the startup probe has signed + c := dial(t, sock) + c.send("SIGKEY " + gripPrimary) + c.send("SETHASH 10 " + strings.Repeat("ab", 64)) + reply := c.send("PKSIGN") + joined := strings.Join(reply, "\n") + + if strings.Contains(joined, "INQUIRE") || strings.Contains(joined, "4242") || + strings.Contains(joined, "/dev/pts") { + t.Errorf("the agent's inquiry reached the sandbox: %q", reply) + } + if got := a.sawInquireAnswers(); len(got) != 1 || got[0] != tc.answer { + t.Errorf("the agent was answered %q, want [%s]", got, tc.answer) + } + if tc.signs { + if !strings.Contains(joined, "sig-val") || !strings.HasPrefix(terminalOf(reply), "OK") { + t.Errorf("a pinentry notification should not stop the signature: %q", reply) + } + } else { + if strings.Contains(joined, "sig-val") || !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Errorf("a cancelled inquiry should end in the agent's ERR: %q", reply) + } + if !log.contains("cancelled agent inquiry") { + t.Errorf("the cancel was not audited: %v", log.lines()) + } + } + // Either way the session is in step and usable. + if got := terminalOf(c.send("RESET")); !strings.HasPrefix(got, "OK") { + t.Errorf("RESET afterwards answered %q", got) + } + }) + } +} + +// TestReplyOverflowEndsTheSession: an agent reply past maxReply cannot be +// relayed in step, so the proxy answers Forbidden, closes the upstream and +// ends the connection rather than leave the rest of the reply to be read as +// the answer to the next command. It would catch a bound that merely stops +// copying and keeps serving. The overflow is simulated by the fake agent; the +// real agent's replies are never that large. +func TestReplyOverflowEndsTheSession(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + _, sock, log := newTestProxy(t, a, pinnedSignOnly()) + a.setBigReply() + c := dial(t, sock) + c.send("SIGKEY " + gripPrimary) + c.send("SETHASH 10 " + strings.Repeat("ab", 64)) + c.write("PKSIGN") + seen := c.drain() + if len(seen) == 0 || seen[len(seen)-1] != errForbidden { + last := "" + if len(seen) > 0 { + last = truncate(seen[len(seen)-1], 60) + } + t.Fatalf("the overflow did not end in %q; %d lines, last %q", errForbidden, len(seen), last) + } + total := 0 + for _, l := range seen { + total += len(l) + } + if total > maxReply+maxLine { + t.Errorf("%d bytes reached the sandbox, over the %d bound", total, maxReply) + } + if !log.contains("exceeded the limit") { + t.Errorf("the overflow was not audited: %v", log.lines()) + } +} + +// TestCloseReturnsWithASilentClient: a sandbox connection that never sends a +// byte sits in a blocking read, and Close waits for its handler. Before Close +// ended the live connections, teardown hung for as long as the payload kept the +// socket open. The silent-mid-inquiry case is the same wait on a different +// read. +func TestCloseReturnsWithASilentClient(t *testing.T) { + check := func(t *testing.T, p *Proxy, c *client) { + done := make(chan struct{}) + go func() { p.Close(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("Close did not return while a client was connected and silent") + } + // Positive control: the connection was live, and Close is what ended it. + c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := readLine(c.r); err == nil || errors.Is(err, os.ErrDeadlineExceeded) { + t.Errorf("the client connection survived Close (err=%v)", err) + } + } + t.Run("idle", func(t *testing.T) { + a := newFakeAgent(t, gripPrimary) + p, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + check(t, p, dial(t, sock)) + }) + t.Run("mid inquiry", func(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + p, c, _ := startDecrypt(t, a) + check(t, p, c) + }) +} + +// TestSilentUpstreamIsBounded: an agent that accepts and never greets must not +// hold a sandbox command forever. The greeting is read under probeTimeout. It +// takes that long to run, so -short skips it. +func TestSilentUpstreamIsBounded(t *testing.T) { + if testing.Short() { + t.Skip("waits out probeTimeout") + } + a := newFakeAgent(t, gripPrimary) + p, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + + path := shortDir(t) + "/mute.sock" + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + accepted := make(chan struct{}, 4) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + accepted <- struct{}{} + t.Cleanup(func() { c.Close() }) + } + }() + p.upstream = path + + c := dial(t, sock) + start := time.Now() + reply := c.send("RESET") + if !strings.HasPrefix(terminalOf(reply), "ERR") { + t.Fatalf("a mute upstream answered %q, want ERR", reply) + } + if took := time.Since(start); took > probeTimeout+3*time.Second { + t.Errorf("answered after %s, over probeTimeout %s", took, probeTimeout) + } + select { + case <-accepted: + default: + t.Error("control: the mute listener was never dialled, so the bound was not exercised") + } +} + +// TestSetKeyAnotherNeedsAPriorSetKey: `--another` adds a grip to the key a +// plain SETKEY chose, so as the first selection on a connection (or after a +// refused one) there is nothing to add to. It would catch the option being +// read as a way to select a key without the plain verb's checks. +func TestSetKeyAnotherNeedsAPriorSetKey(t *testing.T) { + a := newFakeAgent(t, gripSubkey) + _, sock, _ := newTestProxy(t, a, pinnedDecryptOnly()) + c := dial(t, sock) + c.send("RESET") + if got := terminalOf(c.send("SETKEY --another " + gripSubkey)); !strings.HasPrefix(got, "ERR") { + t.Fatalf("SETKEY --another as the first selection answered %q, want ERR", got) + } + if got := a.sawDecrypt(); len(got) != 0 { + t.Errorf("decrypted: %v", got) + } +} + +// TestIsTerminalIsExact: a terminal line is OK or ERR as a whole word. A prefix +// test took `OKAY` and `ERROR` for the end of a reply and left the proxy out of +// step with the agent. +func TestIsTerminalIsExact(t *testing.T) { + for line, want := range map[string]bool{ + "OK": true, "OK Pleased": true, "ERR": true, "ERR 67109115 Forbidden ": true, + "OKAY": false, "OKx": false, "ERROR": false, "ERRx": false, "": false, + "D OK": false, "S OK": false, "ok": false, "INQUIRE CIPHERTEXT": false, + } { + if got := isTerminal(line); got != want { + t.Errorf("isTerminal(%q) = %v, want %v", line, got, want) + } + } +} + +// TestHaveKeyMultiGripIsOKWhenAnyIsPinned: gpg asks `HAVEKEY +// ` as its fast-path listing and the agent answers OK when any named +// grip is held. All-must-be-pinned made a profile pinning only the subkeys +// fail that probe. The agent holds the unpinned grip here, so a proxy that +// forwarded the question would answer OK for the unpinned-only line too. +func TestHaveKeyMultiGripIsOKWhenAnyIsPinned(t *testing.T) { + a := newFakeAgent(t, gripPrimary, gripOther) + _, sock, _ := newTestProxy(t, a, pinnedSignOnly()) + c := dial(t, sock) + before := a.contactCount() + if got := terminalOf(c.send("HAVEKEY " + gripPrimary + " " + gripOther)); !strings.HasPrefix(got, "OK") { + t.Errorf("HAVEKEY answered %q, want OK", got) + } + if got := terminalOf(c.send("HAVEKEY " + gripOther + " " + gripPrimary)); !strings.HasPrefix(got, "OK") { + t.Errorf("HAVEKEY answered %q, want OK", got) + } + if got := terminalOf(c.send("HAVEKEY " + gripOther)); !strings.HasPrefix(got, "ERR") { + t.Errorf("HAVEKEY answered %q, want ERR", got) + } + if got := terminalOf(c.send("HAVEKEY " + gripOther + " " + "0000000000000000000000000000000000000002")); !strings.HasPrefix(got, "ERR") { + t.Errorf("HAVEKEY answered %q, want ERR", got) + } + if a.contactCount() != before { + t.Error("a HAVEKEY was forwarded to the agent") + } +} diff --git a/internal/policy/bindfd_test.go b/internal/policy/bindfd_test.go index 9d59bf72..90610707 100644 --- a/internal/policy/bindfd_test.go +++ b/internal/policy/bindfd_test.go @@ -315,3 +315,28 @@ func TestGoldenFilesHaveNoPathBindOutsideProc(t *testing.T) { } } } + +// TestTmpfsPermsReachTheArgv: a KindTmpfs with Perms is emitted with --perms +// in front of its --tmpfs, and one without is not. identity.gpg relies on it +// for a 0700 GNUPGHOME — gpg warns "unsafe permissions on homedir" under +// bwrap's default 0755. +func TestTmpfsPermsReachTheArgv(t *testing.T) { + p := mustResolveDefaults(t) + perm := uint32(0o700) + p.Mounts["/zz/private"] = Mount{Guest: "/zz/private", Kind: KindTmpfs, Access: AccessRW, Perms: &perm} + p.Mounts["/zz/plain"] = Mount{Guest: "/zz/plain", Kind: KindTmpfs, Access: AccessRW} + args := p.BwrapArgs(1000, 1000) + before := func(guest string) []string { + i := slices.Index(args, guest) + if i < 0 || args[i-1] != "--tmpfs" { + t.Fatalf("no --tmpfs %s in %v", guest, args) + } + return args[max(0, i-5):i] + } + if got := before("/zz/private"); !slices.Contains(got, "0700") || got[len(got)-5] != "--perms" { + t.Errorf("tmpfs with Perms emitted %v, want --perms 0700 --size N --tmpfs", got) + } + if got := before("/zz/plain"); got[0] == "--perms" || got[2] != "--size" { + t.Errorf("tmpfs without Perms emitted %v, want no --perms", got) + } +} diff --git a/internal/policy/bookenvnames_test.go b/internal/policy/bookenvnames_test.go index 3b56b364..5c9de16d 100644 --- a/internal/policy/bookenvnames_test.go +++ b/internal/policy/bookenvnames_test.go @@ -178,6 +178,7 @@ var bookOwnedGroups = []struct { var bookConditionalWhen = map[string]string{ "CONTAINER_HOST": "`podman` is `\"socket\"` or `\"build\"`", "SSH_AUTH_SOCK": "`identity.ssh.agent` is `\"proxy\"`", + "GNUPGHOME": "an `identity.gpg` block", "GH_CONFIG_DIR": "`identity.gh.user` is set", "GIT_CONFIG_GLOBAL": "an `identity.ssh` or `identity.git` block", "KUBECONFIG": "an `identity.kubectl` block", diff --git a/internal/policy/bwrap.go b/internal/policy/bwrap.go index e4ea6e83..6d17c637 100644 --- a/internal/policy/bwrap.go +++ b/internal/policy/bwrap.go @@ -285,6 +285,11 @@ func (p *Policy) BwrapFlags(uid, gid int, fds FDs) []string { // one insertion away from a hard failure. bwrap fails closed here, which // is why this is a correctness note and not a security note — unlike // --seccomp after `--`, a misplaced --size cannot silently do nothing. + if m.Perms != nil { + // --perms and --size each modify the next --tmpfs and bwrap + // 0.12.0 accepts them in either order (measured, both 0700). + a = append(a, "--perms", fmt.Sprintf("%04o", *m.Perms)) + } a = append(a, "--size", strconv.FormatUint(p.TmpfsSizeBytes, 10), "--tmpfs", m.Guest) case KindSymlink: a = append(a, "--symlink", m.Host, m.Guest) diff --git a/internal/policy/envconditional.go b/internal/policy/envconditional.go index d1517879..1a51a555 100644 --- a/internal/policy/envconditional.go +++ b/internal/policy/envconditional.go @@ -84,6 +84,15 @@ var conditionalEnvs = []conditionalEnv{ by: func(prof *Profile) bool { return profileSetsBlock(prof, "ssh") }, writes: "points ssh at its one-key agent proxy", }, + { + // The gpg block IS the proxy, so the pin and the write coincide: snug + // sets GNUPGHOME whenever a resolved identity pins a gpg key. + names: []string{"GNUPGHOME"}, + key: func(*Profile) string { return "identity.gpg.fingerprint" }, + on: func(p *Policy) bool { return p.Identity.Pinned("gpg") }, + by: func(prof *Profile) bool { return profileSetsBlock(prof, "gpg") }, + writes: "points gpg at the ~/.gnupg whose agent socket is its filtering proxy", + }, { names: []string{"GH_CONFIG_DIR", "GH_HOST"}, outranks: []string{"GH_ENTERPRISE_TOKEN", "GH_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "GITHUB_TOKEN"}, @@ -93,10 +102,15 @@ var conditionalEnvs = []conditionalEnv{ writes: "points gh at the config it generates for that one account", }, { - names: []string{"GIT_CONFIG_GLOBAL"}, - key: func(*Profile) string { return "identity" }, - on: func(p *Policy) bool { return p.Identity.Pinned("git") || p.Identity.Pinned("ssh") }, - by: func(prof *Profile) bool { return profileSetsBlock(prof, "git") || profileSetsBlock(prof, "ssh") }, + names: []string{"GIT_CONFIG_GLOBAL"}, + key: func(*Profile) string { return "identity" }, + on: func(p *Policy) bool { + return p.Identity.Pinned("git") || p.Identity.Pinned("ssh") || p.Identity.gpgSigns() + }, + by: func(prof *Profile) bool { + return profileSetsBlock(prof, "git") || profileSetsBlock(prof, "ssh") || + (prof.Identity != nil && prof.Identity.gpgSigns()) + }, writes: "points git at the ~/.gitconfig it generates", }, { diff --git a/internal/policy/envconditional_test.go b/internal/policy/envconditional_test.go index 9a15af4f..88b9ef38 100644 --- a/internal/policy/envconditional_test.go +++ b/internal/policy/envconditional_test.go @@ -152,15 +152,15 @@ func TestConditionalEnvNamesPassParseTime(t *testing.T) { t.Errorf("%s is both unconditionally and conditionally owned", n) } } - // Eleven written (the nine of issue #621, LISTEN_PID which the #621 red - // team found exported by the staged door script, and BROWSER for the - // login bridge of issue #455, KUBECONFIG for identity.kubectl), five that - // outrank them. - if got := len(ConditionalEnvNames()); got != 12 { - t.Errorf("ConditionalEnvNames() = %v, want 12 names", ConditionalEnvNames()) - } - if got := len(ConditionalEnvClaimed()); got != 17 { - t.Errorf("ConditionalEnvClaimed() = %v, want 17 names", ConditionalEnvClaimed()) + // Twelve written (the nine of issue #621, LISTEN_PID which the #621 red + // team found exported by the staged door script, BROWSER for the + // login bridge of issue #455, KUBECONFIG for identity.kubectl, and + // GNUPGHOME for identity.gpg), five that outrank them. + if got := len(ConditionalEnvNames()); got != 13 { + t.Errorf("ConditionalEnvNames() = %v, want 13 names", ConditionalEnvNames()) + } + if got := len(ConditionalEnvClaimed()); got != 18 { + t.Errorf("ConditionalEnvClaimed() = %v, want 18 names", ConditionalEnvClaimed()) } } diff --git a/internal/policy/gitconfig.go b/internal/policy/gitconfig.go index 778f8720..aa4dae39 100644 --- a/internal/policy/gitconfig.go +++ b/internal/policy/gitconfig.go @@ -48,7 +48,17 @@ func GitConfigFrom(id *Identity, home string) []byte { // Belt and braces, the same shape as withoutControlCharacters: Resolve has // already refused signing_key with agent = none, and this is the backstop // for whatever calls the renderer next. - signing := id.Git.SigningKey != "" && id.SSH.Agent != SSHNone + sshSigning := id.Git.SigningKey != "" && id.SSH.Agent != SSHNone + // GPG commit signing, on exactly the same footing as the ssh arm above: a + // key served by an agent the resolver has already required, and which + // gpgproxy.New has already asked the host agent to SIGN with once before + // the listener was bound. The SCOPE is what decides it — a pin scoped only + // "decrypt" must NOT turn commit signing on, or a decryption grant would + // silently be a signing oracle for the same key. `gpg.format` is one value, + // so the two arms cannot both write it; Resolve refuses that combination + // (refuseTwoSigningKeys) rather than picking a winner here. + gpgSigning := id.GPG.Fingerprint != "" && GPGScopeHas(id.GPG.Scope, GPGScopeSign) + signing := sshSigning || gpgSigning // Second guard, deliberately not the only one. The identity block is // validated at load, and this is the backstop for whatever calls the renderer // next — because the failure it prevents is not cosmetic: a newline inside @@ -72,11 +82,18 @@ func GitConfigFrom(id *Identity, home string) []byte { if email != "" { fmt.Fprintf(&b, "\temail = %s\n", gitQuote(email)) } - if signing { + if sshSigning { fmt.Fprintf(&b, "\tsigningkey = %s\n", gitQuote(home+"/"+SigningKeyGuest)) } + if gpgSigning { + // The FINGERPRINT, not a path: git's openpgp backend hands this to + // gpg, which matches the key inside the agent by fingerprint. The + // sandbox reads no key file for it, so there is nothing to stage for + // this value — the public keyring is staged by internal/cli. + fmt.Fprintf(&b, "\tsigningkey = %s\n", gitQuote(id.GPG.Fingerprint)) + } } - if signing { + if sshSigning { b.WriteString("# AUTHORED from identity.git.signing_key: the private half stays in the\n") b.WriteString("# host agent and only the .pub is inside.\n") b.WriteString("# gpgsign is true because snug asked your host agent for one signature with\n") @@ -94,6 +111,21 @@ func GitConfigFrom(id *Identity, home string) []byte { gitQuote(home+"/"+AllowedSignersGuest)) b.WriteString("[commit]\n\tgpgsign = true\n") } + if gpgSigning { + b.WriteString("# AUTHORED from identity.gpg: the private half stays in the host agent and\n") + b.WriteString("# the sandbox's ~/.gnupg holds only the public keyring, whose S.gpg-agent\n") + b.WriteString("# is snug's filtering proxy.\n") + b.WriteString("# gpgsign is true because snug asked your host agent for one signature with\n") + b.WriteString("# this key at startup and got one. A key the agent merely LISTS can still\n") + b.WriteString("# refuse every signature (a pinentry prompt with no tty, a smartcard that\n") + b.WriteString("# is not inserted), which would fail every commit here naming no cause.\n") + b.WriteString("[gpg]\n\tformat = openpgp\n") + // No allowedSignersFile: that is the ssh backend's verifier list. The + // openpgp backend verifies against gpg's keyring, and the sandbox's + // keyring holds exactly the pinned key (staged by internal/cli), so a run + // verifies the key the profile pinned and nothing else. + b.WriteString("[commit]\n\tgpgsign = true\n") + } if id.SSH.Agent != SSHNone { host := id.SSHHost() fmt.Fprintf(&b, "[url \"git@%s:\"]\n\tinsteadOf = https://%s/\n", host, host) diff --git a/internal/policy/gitconfig_test.go b/internal/policy/gitconfig_test.go index bd8e95ad..f23c6cb0 100644 --- a/internal/policy/gitconfig_test.go +++ b/internal/policy/gitconfig_test.go @@ -123,3 +123,85 @@ func TestGitConfigFromOmitsAllowedSignersFileWhenNotSigning(t *testing.T) { t.Errorf("generated config authors an allowedSignersFile with no signing_key pinned:\n%s", out) } } + +// TestGitConfigFromAuthorsOpenPGPSigningFromAGPGPin is the gpg arm's parity +// with the ssh one: a sign-scoped identity.gpg turns on commit signing, names +// the FINGERPRINT as user.signingkey (git's openpgp backend hands that string +// to gpg, which matches the key inside the agent by fingerprint — no path is +// involved), and never authors the ssh backend's allowedSignersFile. +func TestGitConfigFromAuthorsOpenPGPSigningFromAGPGPin(t *testing.T) { + const fp = "6732361300BBFDAE7F22972CD4381E1CE2880244" + id := &Identity{GPG: IdentityGPG{Fingerprint: fp, Scope: "sign"}, Git: IdentityGit{Email: "u@example.com"}} + out := string(GitConfigFrom(id, "/home/u")) + + if !strings.Contains(directives(out), `signingkey = "`+fp+`"`) { + t.Errorf("generated config has no signingkey directive naming the fingerprint %q:\n%s", fp, out) + } + if !strings.Contains(directives(out), "format = openpgp") { + t.Errorf("generated config does not set gpg.format = openpgp:\n%s", out) + } + if !strings.Contains(directives(out), "gpgsign = true") { + t.Errorf("generated config does not set commit.gpgsign = true:\n%s", out) + } + if strings.Contains(directives(out), "allowedSignersFile") { + t.Errorf("the openpgp arm authored an ssh-only allowedSignersFile:\n%s", out) + } +} + +// A DECRYPT-only gpg pin authors NO signing config: turning commit.gpgsign on +// for a pin that never granted "sign" would make a decryption grant a signing +// oracle for the same key. +func TestGitConfigFromDecryptOnlyGPGPinAuthorsNoSigning(t *testing.T) { + id := &Identity{ + GPG: IdentityGPG{Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", Scope: "decrypt"}, + Git: IdentityGit{Email: "u@example.com"}, + } + out := string(GitConfigFrom(id, "/home/u")) + for _, forbidden := range []string{"signingkey", "gpgsign", "format ="} { + if strings.Contains(directives(out), forbidden) { + t.Errorf("a decrypt-only gpg pin authored %q:\n%s", forbidden, out) + } + } +} + +// TestRefuseTwoSigningKeysRefusesSSHAndGPGBothSigning / its decrypt-only +// control: gpg.format is one value, so two signing keys is a pin that does +// nothing, refused naming both fields. +func TestRefuseTwoSigningKeysRefusesSSHAndGPGBothSigning(t *testing.T) { + // maximalProfile grants a runnable sandbox, so the CONTROL below reaches a + // successful resolve rather than the "grants nothing" floor. + base := func() *Profile { + p := maximalProfile() + p.Identity.SSH = IdentitySSH{Host: "gh.example", Key: "/home/u/.ssh/id.pub", Agent: SSHAgentProxy} + p.Identity.Git = IdentityGit{Name: "Some One", Email: "u@example.com", + SigningKey: "/home/u/.ssh/id_sign.pub"} + p.Identity.GPG = IdentityGPG{Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", Scope: "sign"} + return p + } + resolveWith := func(t *testing.T, p *Profile) error { + t.Helper() + reg := testRegistry() + reg[p.Name] = p + _, err := Resolve(reg, []ProfileName{p.Name}, testCtx(), newFakeEnv()) + return err + } + + err := resolveWith(t, base()) + if err == nil { + t.Fatal("an ssh signing key and a sign-scoped gpg key resolved together; " + + "git would use one format and silently ignore the other pin") + } + for _, want := range []string{"identity.git.signing_key", "identity.gpg"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal does not name %q: %v", want, err) + } + } + + // The control: the same key scoped DECRYPT-only authors no signing config, + // so it composes with the ssh signing key. + p := base() + p.Identity.GPG.Scope = "decrypt" + if err := resolveWith(t, p); err != nil { + t.Errorf("a decrypt-only gpg pin alongside an ssh signing key was refused: %v", err) + } +} diff --git a/internal/policy/identity.go b/internal/policy/identity.go index 70c06ad9..b965457b 100644 --- a/internal/policy/identity.go +++ b/internal/policy/identity.go @@ -99,6 +99,7 @@ func ParseKubeCredential(s string) (KubeCredential, error) { type Identity struct { SSH IdentitySSH `snug:"ssh"` Git IdentityGit `snug:"git"` + GPG IdentityGPG `snug:"gpg"` Gh IdentityGh `snug:"gh"` Kube IdentityKube `snug:"kubectl"` } @@ -190,6 +191,142 @@ type IdentityGh struct { User string `snug:"user"` } +// IdentityGPG pins one OpenPGP key, by FINGERPRINT, for signing and/or +// decryption through snug's filtering proxy to the host's gpg-agent. +// +// A hostile process inside the sandbox can use this to sign anything as you +// with the pinned signing subkey and to decrypt anything addressed to the +// pinned encryption subkey, for as long as the run lasts. It cannot certify: +// the primary key is never pinned (internal/cli/gpgidentity.go, gpgGrips). +// +// THERE IS NO `agent` KNOB, and its absence is the whole design: unlike +// identity.ssh, where "no agent" is a real mode — the sandbox keeps its own +// keys on its own disk — a pinned gpg key is USELESS without the agent. The +// private half lives in the host agent and nowhere else, so a block that names +// a fingerprint has, by construction, selected the proxy. A second key saying +// "proxy" would be a knob whose only accepted value is the only value the +// block can mean, which is the disease CLAUDE.md refuses. +// +// Fingerprint, NOT a path: the key is matched inside the HOST agent, which +// knows it by fingerprint; snug reads no key file for this block. So it is a +// string leaf with no `path` role and no staging of key material. +// +// Scope is a STRING LEAF holding the CANONICAL, comma-joined, sorted set of +// granted capabilities — "sign", "decrypt" or "decrypt,sign" — and not a +// []string or a bitmask field, because Identity is walked by mustIdentityFields, +// which supports exactly nested structs and strings, and because IDENTITY +// BLOCKS ARE COMPARED WITH == AND reflect.Value.Equal, which a slice would +// break. ParseGPGRuntimeScope is the door that produces this spelling from the +// profile's array; internal/cli parses it back. +type IdentityGPG struct { + // Fingerprint is the 40-hex-character OpenPGP key fingerprint, the same + // string `gpg --fingerprint` prints. The primary key signs; its encryption + // subkey decrypts, and internal/cli derives the keygrips for both. + Fingerprint string `snug:"fingerprint"` + + // Scope is the canonical comma-joined set of capabilities granted: + // "sign", "decrypt" or "decrypt,sign". Set by ParseGPGRuntimeScope; never + // written by a hand-rolled literal except in tests. + Scope string `snug:"scope"` +} + +// GPGScopeSign and GPGScopeDecrypt are the two capabilities a profile may +// spell. There is deliberately no "encrypt": measured, encrypting to the +// pinned key produced an EMPTY host-agent log — gpg encrypts from the public +// key alone — so a value accepted for it would grant nothing. There is +// deliberately no "auth": gpg-agent's ssh-emulation side is a protocol snug +// has not measured, and a value whose verbs are not read into the proxy would +// be a scope that cannot be enforced. +const ( + GPGScopeSign = "sign" + GPGScopeDecrypt = "decrypt" +) + +// ParseGPGRuntimeScope validates a profile's `scope` array and returns the +// CANONICAL spelling: sorted, de-duplicated, comma-joined. It refuses an empty +// set (a block that names a key must say what it grants — the same reason +// `podman = "off"` is refused), an unknown value, and a duplicate, and it +// names the accepted set in every refusal. +// +// It returns a STRING rather than a bitmask because the result is stored in +// IdentityGPG.Scope, which is compared with == and rendered into refusals. +func ParseGPGRuntimeScope(values []string) (string, error) { + if len(values) == 0 { + return "", fmt.Errorf("identity.gpg.scope sets nothing; "+ + "name what the sandbox may do with this key (want %s or %s, or both)", + GPGScopeSign, GPGScopeDecrypt) + } + seen := map[string]bool{} + for _, v := range values { + switch v { + case GPGScopeSign, GPGScopeDecrypt: + default: + return "", fmt.Errorf("unknown identity.gpg.scope %q (want %s or %s)", + v, GPGScopeSign, GPGScopeDecrypt) + } + if seen[v] { + return "", fmt.Errorf("identity.gpg.scope names %q twice; "+ + "the set is a union, so a repeat says nothing", v) + } + seen[v] = true + } + out := make([]string, 0, len(seen)) + for v := range seen { + out = append(out, v) + } + slices.Sort(out) + return strings.Join(out, ","), nil +} + +// GPGScopeHas reports whether the canonical scope spelling grants cap. It is +// the reader for a stored IdentityGPG.Scope; internal/cli uses it to build the +// per-grip gpgproxy.Scope. +func GPGScopeHas(scope, cap string) bool { + for part := range strings.SplitSeq(scope, ",") { + if part == cap { + return true + } + } + return false +} + +// ParseFingerprint is the door into IdentityGPG.Fingerprint. It accepts the +// v4/v5 OpenPGP fingerprint: 40 or 64 hexadecimal characters, case-insensitive, +// and returns it UPPERCASE so that two profiles naming the same key in two +// cases compare equal (the identity join is textual). +// +// It is a door rather than a validation inside Resolve because the fingerprint +// is a property of the profile TEXT: `snug profile show`, which never resolves, +// must report it like every other bad key. internal/cli also calls it before +// handing the value to gpg, so an unsettable spelling never reaches `gpg +// --list-secret-keys`. +// gpgSigns reports whether the identity pins a gpg key with the sign scope — +// the one gpg pin git has a use for. +func (id *Identity) gpgSigns() bool { + return id.Pinned("gpg") && GPGScopeHas(id.GPG.Scope, GPGScopeSign) +} + +func ParseFingerprint(s string) (string, error) { + up := strings.ToUpper(s) + if up == "" { + return "", fmt.Errorf("identity.gpg.fingerprint is empty; " + + "name the key by its fingerprint (the string `gpg --fingerprint` prints)") + } + n := len(up) + if n != 40 && n != 64 { + return "", fmt.Errorf("identity.gpg.fingerprint %q has %d characters; "+ + "a fingerprint is 40 (v4) or 64 (v5) hexadecimal characters", s, n) + } + for i := 0; i < n; i++ { + c := up[i] + if (c < '0' || c > '9') && (c < 'A' || c > 'F') { + return "", fmt.Errorf("identity.gpg.fingerprint %q is not hexadecimal "+ + "(the offending byte is %q)", s, string(s[i])) + } + } + return up, nil +} + // A pointer, slice or map field would make this fail to compile — which is worth // having, but it is NOT the guard for the three failures listed above // IdentitySSH: a pointer is comparable, so this line passes while every one of @@ -463,6 +600,22 @@ const PubKeyGuest = ".ssh/id_snug.pub" // harmless — cheaper than a branch a reader has to simulate. const SigningKeyGuest = ".ssh/id_snug_signing.pub" +// GPGHomeGuest is the sandbox's $GNUPGHOME, and GPGAgentSocketGuest is where +// the filtering proxy's socket must sit inside it. +// +// NOT under SnugDir, unlike AgentSocketGuest: gpg derives the agent socket from +// the CLIENT's GNUPGHOME (`gpgconf --list-dirs agent-socket` answers +// "$GNUPGHOME/S.gpg-agent"), so a socket anywhere else is simply not looked at. +// The name S.gpg-agent is gpg's, not snug's, and it is fixed. +const ( + GPGHomeGuest = ".gnupg" + GPGAgentSocketGuest = GPGHomeGuest + "/S.gpg-agent" + // GPGKeyringGuest is the staged PUBLIC keyring, holding exactly the pinned + // key. gpg reads it to know the fingerprint it may sign and encrypt with; + // the private half never leaves the host agent. + GPGKeyringGuest = GPGHomeGuest + "/pubring.kbx" +) + // AllowedSignersGuest is the generated `gpg.ssh.allowedSignersFile`. It holds // exactly ONE line — the pinned signing key, under the pinned git email — so a // run can verify its OWN commits and nobody else's. diff --git a/internal/policy/identityconflict_test.go b/internal/policy/identityconflict_test.go index 6731ed21..02a08b91 100644 --- a/internal/policy/identityconflict_test.go +++ b/internal/policy/identityconflict_test.go @@ -117,6 +117,7 @@ func baseNestedIdentity() Identity { return Identity{ SSH: IdentitySSH{Host: "fixed.example", Key: "/home/u/.ssh/id_auth.pub", Agent: SSHAgentProxy}, Git: IdentityGit{Name: "Base Name", Email: "base@example.com", SigningKey: "/home/u/.ssh/id_sign.pub"}, + GPG: IdentityGPG{Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", Scope: "sign"}, Gh: IdentityGh{Host: "fixed.example", User: "base-user"}, Kube: IdentityKube{Context: "aks-dev", Credential: KubeCredentialClientCert, Server: "https://aks-dev.example.azmk8s.io:443", Kubeconfig: "/home/u/.kube/config"}, @@ -156,6 +157,18 @@ func TestIdentityPinRefusesTwoProfilesDifferingOnlyInEachLeaf(t *testing.T) { // one profile. av.SetString(string(KubeCredentialClientCert)) bv.SetString(string(KubeCredentialToken)) + } else if f.Key == "gpg.fingerprint" { + // Two ACCEPTED fingerprints: appending "-a" would make an invalid + // one, and the parser would name ident-a instead of the conflict + // this subtest is about. Same two-different-values shape as + // kubectl.credential above. + av.SetString("6732361300BBFDAE7F22972CD4381E1CE2880244") + bv.SetString("0000000000000000000000000000000000000000") + } else if f.Key == "gpg.scope" { + // Both accepted, both canonical, both non-empty: the conflict is + // the difference, not the absence of a key. + av.SetString("sign") + bv.SetString("decrypt") } else { av.SetString(av.String() + "-a") bv.SetString(bv.String() + "-b") diff --git a/internal/policy/identitygpg_test.go b/internal/policy/identitygpg_test.go new file mode 100644 index 00000000..fec656bd --- /dev/null +++ b/internal/policy/identitygpg_test.go @@ -0,0 +1,194 @@ +package policy + +import ( + "reflect" + "strings" + "testing" +) + +// The two doors identity.gpg passes through. Both live here rather than only in +// the profile loader because an Identity built in Go reaches Resolve without +// internal/profile, and both are properties of the profile TEXT that +// `snug profile show` — which never resolves — must report. + +func TestParseFingerprintAcceptsHexAndNormalisesCase(t *testing.T) { + cases := []struct{ in, want string }{ + // The measured key, in both cases, upper and lower. + {"6732361300BBFDAE7F22972CD4381E1CE2880244", "6732361300BBFDAE7F22972CD4381E1CE2880244"}, + {"6732361300bbfdae7f22972cd4381e1ce2880244", "6732361300BBFDAE7F22972CD4381E1CE2880244"}, + // A 64-character v5 fingerprint is accepted too. + {"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"}, + } + for _, c := range cases { + got, err := ParseFingerprint(c.in) + if err != nil { + t.Errorf("ParseFingerprint(%q) refused: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("ParseFingerprint(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseFingerprintRefusesTheWrongShape(t *testing.T) { + cases := []struct{ name, in, wants string }{ + {"empty", "", "is empty"}, + {"too short", "6732361300BBFDAE7F22972CD4381E1CE288024", "has 39 characters"}, + {"too long", "6732361300BBFDAE7F22972CD4381E1CE2880244FF", "has 42 characters"}, + {"40 but not hex", "6732361300BBFDAE7F22972CD4381E1CE2880ZZ4", "not hexadecimal"}, + {"64 but not hex", "6732361300BBFDAE7F22972CD4381E1CE288024400000000000000000000000Z", + "not hexadecimal"}, + {"path, not a fingerprint", "/home/u/.gnupg/pubring.kbx", "has 26 characters"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ParseFingerprint(c.in) + if err == nil { + t.Fatalf("ParseFingerprint(%q) accepted", c.in) + } + if !strings.Contains(err.Error(), c.wants) { + t.Errorf("refusal = %v, want it to mention %q", err, c.wants) + } + }) + } +} + +func TestParseGPGRuntimeScopeUnionsAndCanonicalises(t *testing.T) { + cases := []struct { + in []string + want string + }{ + {[]string{"sign"}, "sign"}, + {[]string{"decrypt"}, "decrypt"}, + // SORTED, so the two orders of the same set are one value. + {[]string{"sign", "decrypt"}, "decrypt,sign"}, + {[]string{"decrypt", "sign"}, "decrypt,sign"}, + } + for _, c := range cases { + got, err := ParseGPGRuntimeScope(c.in) + if err != nil { + t.Errorf("ParseGPGRuntimeScope(%v) refused: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("ParseGPGRuntimeScope(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseGPGRuntimeScopeRefusesAnEmptySet(t *testing.T) { + // The load-bearing refusal: a block that names a key must say what it + // grants. An empty array is the `podman = "off"` disease — a value that is + // accepted and grants nothing. + for _, in := range [][]string{nil, {}} { + _, err := ParseGPGRuntimeScope(in) + if err == nil { + t.Fatalf("ParseGPGRuntimeScope(%v) accepted an empty set", in) + } + for _, want := range []string{"sets nothing", "sign", "decrypt"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal = %v, want it to name %q", err, want) + } + } + } +} + +func TestParseGPGRuntimeScopeRefusesUnknownAndRepeatedValues(t *testing.T) { + cases := []struct { + name, in, wants string + }{ + {"encrypt is not a value", "encrypt", "unknown identity.gpg.scope"}, + {"auth is not a value", "auth", "unknown identity.gpg.scope"}, + {"empty both", "none", "unknown identity.gpg.scope"}, + {"repeated", "sign,sign", "names \"sign\" twice"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ParseGPGRuntimeScope(strings.Split(c.in, ",")) + if err == nil { + t.Fatalf("ParseGPGRuntimeScope(%q) accepted", c.in) + } + if !strings.Contains(err.Error(), c.wants) { + t.Errorf("refusal = %v, want it to mention %q", err, c.wants) + } + }) + } + // An unknown value names the accepted set, so the message is the fix. + _, err := ParseGPGRuntimeScope([]string{"encrypt"}) + for _, want := range []string{"sign", "decrypt"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("an unknown-value refusal does not name %q: %v", want, err) + } + } +} + +func TestGPGScopeHas(t *testing.T) { + cases := []struct { + scope, cap string + want bool + }{ + {"sign", "sign", true}, + {"sign", "decrypt", false}, + {"decrypt", "decrypt", true}, + {"decrypt", "sign", false}, + {"decrypt,sign", "sign", true}, + {"decrypt,sign", "decrypt", true}, + {"decrypt,sign", "encrypt", false}, + {"", "sign", false}, + } + for _, c := range cases { + if got := GPGScopeHas(c.scope, c.cap); got != c.want { + t.Errorf("GPGScopeHas(%q, %q) = %v, want %v", c.scope, c.cap, got, c.want) + } + } +} + +// TestIdentityGPGFingerprintIsNotAPathLeaf pins the shape: the fingerprint has +// no `path` role, because snug reads no key file for this block — the host +// agent holds the key. A `path` tag here would send it through Resolve's +// expand-and-symlink-check loop and produce a refusal telling a human to copy a +// PUBLIC half, which is the wrong instruction for a fingerprint. +func TestIdentityGPGFingerprintIsNotAPathLeaf(t *testing.T) { + var fingerprint *identityField + for i := range identityFields { + if identityFields[i].Key == "gpg.fingerprint" { + fingerprint = &identityFields[i] + } + } + if fingerprint == nil { + t.Fatal("identityFields has no gpg.fingerprint; the block did not derive") + } + if fingerprint.Path { + t.Error("gpg.fingerprint is a path leaf; it names a key in the host agent, not a file") + } + if _, ok := identityPathRoles["gpg.fingerprint"]; ok { + t.Error("identityPathRoles has an entry for gpg.fingerprint, which is not a path") + } +} + +// TestIdentityGPGIsItsOwnPinWithTwoLeaves pins the partition: the block joins +// as one pin like every other, and its two leaves are the fingerprint and the +// canonical scope. +func TestIdentityGPGIsItsOwnPinWithTwoLeaves(t *testing.T) { + var leaves []string + for _, f := range identityFields { + if f.Pin == "gpg" { + leaves = append(leaves, f.Key) + } + } + want := []string{"gpg.fingerprint", "gpg.scope"} + if !reflect.DeepEqual(leaves, want) { + t.Errorf("gpg leaves = %v, want %v", leaves, want) + } + found := false + for _, p := range identityPins { + if p.Key == "gpg" { + found = true + } + } + if !found { + t.Error("gpg is not an identity pin") + } +} diff --git a/internal/policy/identitygpghome_test.go b/internal/policy/identitygpghome_test.go new file mode 100644 index 00000000..596d1127 --- /dev/null +++ b/internal/policy/identitygpghome_test.go @@ -0,0 +1,228 @@ +package policy + +import ( + "slices" + "strings" + "testing" +) + +const testGPGFingerprint = "6732361300BBFDAE7F22972CD4381E1CE2880244" + +// gpgHomeEnv is a fake host that has a ~/.gnupg, a directory elsewhere to bind +// there, and a gpg.conf inside it. +func gpgHomeEnv() *fakeEnv { + env := newFakeEnv() + env.dirs["/home/u/.gnupg"] = true + env.dirs["/x/gnupg"] = true + env.files["/home/u/.gnupg/gpg.conf"] = true + return env +} + +// resolveGPG resolves the default selection plus an identity.gpg pin with the +// given scope plus grant (if any), in the order given by grantFirst. +func resolveGPG(t *testing.T, scope string, grant *Profile, grantFirst bool, env *fakeEnv) (*Policy, error) { + t.Helper() + reg := testRegistry() + reg["gpg-pin"] = &Profile{Name: "gpg-pin", Identity: &Identity{ + GPG: IdentityGPG{Fingerprint: testGPGFingerprint, Scope: scope}}} + sel := append([]ProfileName{}, testDefaults...) + if grant != nil { + reg[grant.Name] = grant + if grantFirst { + sel = append(sel, grant.Name, "gpg-pin") + } else { + sel = append(sel, "gpg-pin", grant.Name) + } + } else { + sel = append(sel, "gpg-pin") + } + return Resolve(reg, sel, testCtx(), env) +} + +// TestGPGIdentityRefusesHostGnupgGrant: a profile that grants the host's +// ~/.gnupg, however spelled, cannot be selected beside identity.gpg. The +// generated ~/.gnupg is a tmpfs over that path, which would hide the grant, and +// the grant is the host keyring and possibly the unfiltered agent socket, the +// very hole identity.gpg exists to narrow. Resolve refuses rather than hide +// it, naming both sides, in either selection order. It would catch the tmpfs +// quietly replacing the grant, or the grant quietly replacing the proxy. +func TestGPGIdentityRefusesHostGnupgGrant(t *testing.T) { + cases := []struct { + name string + grant *Profile + }{ + {"ro", &Profile{Name: "host-gnupg", RO: []string{"{home}/.gnupg"}}}, + {"rw", &Profile{Name: "host-gnupg", RW: []string{"{home}/.gnupg"}}}, + {"ro absolute", &Profile{Name: "host-gnupg", RO: []string{"/home/u/.gnupg"}}}, + {"another host dir at the guest path", &Profile{Name: "host-gnupg", RO: []string{"/x/gnupg:{home}/.gnupg"}}}, + } + for _, tc := range cases { + for _, first := range []bool{false, true} { + name := tc.name + " pin-first" + if first { + name = tc.name + " grant-first" + } + t.Run(name, func(t *testing.T) { + // Control: the grant resolves on its own, so the refusal below + // is the interaction and not an unrelated bad profile. + reg := testRegistry() + reg[tc.grant.Name] = tc.grant + if _, err := Resolve(reg, append(append([]ProfileName{}, testDefaults...), tc.grant.Name), + testCtx(), gpgHomeEnv()); err != nil { + t.Fatalf("control: the grant alone does not resolve: %v", err) + } + + _, err := resolveGPG(t, "sign", tc.grant, first, gpgHomeEnv()) + if err == nil { + t.Fatal("a ~/.gnupg grant and identity.gpg resolved together") + } + for _, want := range []string{"/home/u/.gnupg", "host-gnupg", "gpg-pin"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } + }) + } + } +} + +// TestGPGIdentityKeepsDeeperGrantsAndAcceptsATmpfs: a grant UNDER ~/.gnupg is +// mounted after the generated tmpfs and stays visible, and a profile's own +// tmpfs at ~/.gnupg hides nothing, so neither is refused. It would catch the +// refusal widening to every path in the subtree. +func TestGPGIdentityKeepsDeeperGrantsAndAcceptsATmpfs(t *testing.T) { + for _, first := range []bool{false, true} { + deeper := &Profile{Name: "gpg-conf", RO: []string{"/home/u/.gnupg/gpg.conf"}} + p, err := resolveGPG(t, "sign", deeper, first, gpgHomeEnv()) + if err != nil { + t.Fatalf("a grant under ~/.gnupg was refused (grant first=%v): %v", first, err) + } + conf, ok := p.Mounts["/home/u/.gnupg/gpg.conf"] + if !ok { + t.Fatal("the deeper grant is not in the policy") + } + if conf.Kind == KindTmpfs { + t.Errorf("the deeper grant is a tmpfs: %+v", conf) + } + args := p.BwrapArgs(1000, 1000) + tmpfs, deep := slices.Index(args, "/home/u/.gnupg"), slices.Index(args, "/home/u/.gnupg/gpg.conf") + if tmpfs < 0 || deep < 0 || deep < tmpfs { + t.Errorf("the deeper grant is not mounted after the generated tmpfs (tmpfs at %d, grant at %d)", tmpfs, deep) + } + } + + scratch := &Profile{Name: "gnupg-scratch", Tmpfs: []string{"{home}/.gnupg"}} + for _, first := range []bool{false, true} { + p, err := resolveGPG(t, "sign", scratch, first, gpgHomeEnv()) + if err != nil { + t.Fatalf("a profile tmpfs at ~/.gnupg was refused (grant first=%v): %v", first, err) + } + m := p.Mounts["/home/u/.gnupg"] + if m.Kind != KindTmpfs || m.Perms == nil || *m.Perms != 0o700 { + t.Errorf("~/.gnupg = %+v, want the generated 0700 tmpfs to win", m) + } + } +} + +// TestGPGIdentityOwnsItsHome: Resolve, not the CLI, authors ~/.gnupg as a 0700 +// tmpfs and GNUPGHOME pointing at it, and only when identity.gpg is pinned. +// gpg warns "unsafe permissions on homedir" under the 0755 an anchor would give. +func TestGPGIdentityOwnsItsHome(t *testing.T) { + p, err := resolveGPG(t, "decrypt", nil, false, gpgHomeEnv()) + if err != nil { + t.Fatal(err) + } + m, ok := p.Mounts["/home/u/.gnupg"] + if !ok || m.Kind != KindTmpfs { + t.Fatalf("~/.gnupg = %+v (present %v), want a tmpfs", m, ok) + } + if m.Perms == nil || *m.Perms != 0o700 { + t.Errorf("~/.gnupg perms = %v, want 0700", m.Perms) + } + if v, ok := p.EnvValue("GNUPGHOME"); !ok || v != "/home/u/.gnupg" { + t.Errorf("GNUPGHOME = %q (set %v), want /home/u/.gnupg", v, ok) + } + + // Control and negative in one: the same selection with no gpg pin gets + // neither, so the assertions above are about the pin. + q, err := Resolve(testRegistry(), testDefaults, testCtx(), gpgHomeEnv()) + if err != nil { + t.Fatal(err) + } + if _, ok := q.Mounts["/home/u/.gnupg"]; ok { + t.Error("~/.gnupg is mounted without identity.gpg") + } + if _, ok := q.EnvValue("GNUPGHOME"); ok { + t.Error("GNUPGHOME is set without identity.gpg") + } +} + +// TestGPGGitconfigFollowsTheSignScope: a sign scope authors ~/.gitconfig and +// GIT_CONFIG_GLOBAL so commits are signed; a decrypt-only pin authors neither, +// because turning signing on for a key that never granted it would make a +// decryption grant a signing oracle, and would displace a user's own git config +// on behalf of a key unrelated to git. +func TestGPGGitconfigFollowsTheSignScope(t *testing.T) { + for _, tc := range []struct { + scope string + want bool + }{{"sign", true}, {"decrypt,sign", true}, {"decrypt", false}} { + p, err := resolveGPG(t, tc.scope, nil, false, gpgHomeEnv()) + if err != nil { + t.Fatalf("scope %q: %v", tc.scope, err) + } + cfg, hasCfg := p.Mounts["/home/u/.gitconfig"] + _, hasEnv := p.EnvValue("GIT_CONFIG_GLOBAL") + if hasCfg != tc.want || hasEnv != tc.want { + t.Errorf("scope %q: ~/.gitconfig present=%v GIT_CONFIG_GLOBAL set=%v, want both %v", + tc.scope, hasCfg, hasEnv, tc.want) + } + if tc.want && !strings.Contains(string(cfg.Content), "gpgsign = true") { + t.Errorf("scope %q: the generated gitconfig does not turn signing on:\n%s", tc.scope, cfg.Content) + } + } +} + +// TestGPGScopesOfTwoProfilesConflict: two profiles pinning the same key with +// different scopes disagree about what the sandbox may do, and neither is +// widened to the other. sign against decrypt,sign is the case a union would +// quietly resolve to decrypt,sign, handing the second profile's decrypt to a +// selection that only asked for the first. Both orders are refused, naming both. +func TestGPGScopesOfTwoProfilesConflict(t *testing.T) { + a := Identity{GPG: IdentityGPG{Fingerprint: testGPGFingerprint, Scope: "sign"}} + b := Identity{GPG: IdentityGPG{Fingerprint: testGPGFingerprint, Scope: "decrypt,sign"}} + for _, pair := range [][2]*Identity{{&a, &b}, {&b, &a}} { + _, err := Resolve(conflictRegistry(pair[0], pair[1]), conflictSelection(), testCtx(), newFakeEnv()) + if err == nil { + t.Fatalf("scopes %q and %q resolved together", pair[0].GPG.Scope, pair[1].GPG.Scope) + } + for _, want := range []string{"ident-a", "ident-b"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } + } + // Control: identical scopes in two profiles are one identity. + c := a + if _, err := Resolve(conflictRegistry(&a, &c), conflictSelection(), testCtx(), newFakeEnv()); err != nil { + t.Errorf("control: identical gpg pins were refused: %v", err) + } +} + +// TestRefuseTwoSigningKeysNamesOneProfileWhenOneOwnsBoth: a single profile +// carrying an ssh signing key and a sign-scoped gpg key is refused in the +// singular, not as `profiles "x" and "x"`. +func TestRefuseTwoSigningKeysNamesOneProfileWhenOneOwnsBoth(t *testing.T) { + p := maximalProfile() + p.Identity.SSH = IdentitySSH{Host: "gh.example", Key: "/home/u/.ssh/id.pub", Agent: SSHAgentProxy} + p.Identity.GPG = IdentityGPG{Fingerprint: testGPGFingerprint, Scope: "sign"} + reg := testRegistry() + reg[p.Name] = p + _, err := Resolve(reg, []ProfileName{p.Name}, testCtx(), newFakeEnv()) + if err == nil { + t.Fatal("two signing keys in one profile resolved") + } + if strings.Contains(err.Error(), `profiles "maximal" and "maximal"`) || !strings.Contains(err.Error(), `profile "maximal"`) { + t.Errorf("the refusal should name the one profile once: %v", err) + } +} diff --git a/internal/policy/identityperblock_test.go b/internal/policy/identityperblock_test.go index 7810e9ad..2ff75d52 100644 --- a/internal/policy/identityperblock_test.go +++ b/internal/policy/identityperblock_test.go @@ -26,6 +26,7 @@ func identityProfiles(ids map[ProfileName]Identity) (map[ProfileName]*Profile, [ var ( sshBlock = IdentitySSH{Key: "/home/u/.ssh/id_auth.pub", Agent: SSHAgentProxy} gitBlock = IdentityGit{Name: "Base Name", Email: "base@example.com"} + gpgBlock = IdentityGPG{Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", Scope: "sign"} ghBlock = IdentityGh{User: "base-user"} kubeBlock = IdentityKube{Context: "aks-dev", Credential: KubeCredentialClientCert, Server: "https://aks-dev.example.azmk8s.io:443", Kubeconfig: "/home/u/.kube/config"} @@ -35,6 +36,7 @@ func TestIdentityPinPerBlockRefusesNamingBothOwners(t *testing.T) { differ := map[string][2]Identity{ "ssh": {{SSH: sshBlock}, {SSH: IdentitySSH{Key: "/home/u/.ssh/other.pub", Agent: SSHAgentProxy}}}, "git": {{Git: gitBlock}, {Git: IdentityGit{Name: "Other", Email: "other@example.com"}}}, + "gpg": {{GPG: gpgBlock}, {GPG: IdentityGPG{Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", Scope: "decrypt"}}}, "gh": {{Gh: ghBlock}, {Gh: IdentityGh{User: "other-user"}}}, "kubectl": {{Kube: kubeBlock}, {Kube: IdentityKube{Context: "aks-dev", Credential: KubeCredentialToken, Server: "https://aks-dev.example.azmk8s.io:443", Kubeconfig: "/home/u/.kube/config"}}}, diff --git a/internal/policy/identitytext_test.go b/internal/policy/identitytext_test.go index c252eb49..34f99f8c 100644 --- a/internal/policy/identitytext_test.go +++ b/internal/policy/identitytext_test.go @@ -119,6 +119,7 @@ func TestResolveRecordsWhichProfilePinnedTheIdentity(t *testing.T) { var wantIdentityLeaves = []string{ "ssh.host", "ssh.key", "ssh.agent", "git.name", "git.email", "git.signing_key", + "gpg.fingerprint", "gpg.scope", "gh.host", "gh.user", "kubectl.context", "kubectl.server", "kubectl.credential", "kubectl.kubeconfig", } diff --git a/internal/policy/resolve.go b/internal/policy/resolve.go index f1830dad..ef95fe0f 100644 --- a/internal/policy/resolve.go +++ b/internal/policy/resolve.go @@ -472,6 +472,32 @@ func Resolve(reg map[ProfileName]*Profile, selected []ProfileName, ctx Context, if _, err := ParseKubeCredential(string(id.Kube.Credential)); err != nil { return nil, fmt.Errorf("profile %q: %w", name, err) } + // The same two-doors rule as ParseSSHMode: internal/cli and the + // profile loader each parse the array, but an Identity built in Go + // reaches Resolve without them, so the accepted set is enforced + // here too. A half block is refused, because a fingerprint with no + // scope grants nothing and a scope with no key names nothing. + if id.GPG != (IdentityGPG{}) { + fp, err := ParseFingerprint(id.GPG.Fingerprint) + if err != nil { + return nil, fmt.Errorf("profile %q: %w", name, err) + } + if id.GPG.Scope == "" { + return nil, fmt.Errorf("profile %q: identity.gpg pins a key but names "+ + "no scope; a key with no scope grants nothing "+ + "(want %s or %s, or both)", name, GPGScopeSign, GPGScopeDecrypt) + } + scope, err := ParseGPGRuntimeScope(strings.Split(id.GPG.Scope, ",")) + if err != nil { + return nil, fmt.Errorf("profile %q: %w", name, err) + } + if scope != id.GPG.Scope { + return nil, fmt.Errorf("profile %q: identity.gpg.scope %q is not "+ + "canonical; the sorted, comma-joined form is %q", + name, id.GPG.Scope, scope) + } + id.GPG.Fingerprint = fp + } // The default is filled in BEFORE the path loop, so an omitted // kubeconfig, "{home}/.kube/config" and "~/.kube/config" are // expanded, link-walked and compared as one value (#559's lesson, @@ -847,18 +873,30 @@ func Resolve(reg map[ProfileName]*Profile, selected []ProfileName, ctx Context, if err := refuseSigningWithoutAgent(p); err != nil { return nil, err } + // The gpg arm's counterpart: a sign-scoped gpg pin ALSO authors + // commit.gpgsign, and gpg.format is a single value — so a profile that pins + // both an ssh signing key and a sign-scoped gpg key is refused rather than + // silently letting one win. + if err := refuseTwoSigningKeys(p); err != nil { + return nil, err + } if err := refuseHalfNamedHost(p); err != nil { return nil, err } - // The git config is GENERATED from the identity.git and identity.ssh pins, - // and only from them. One writer, one path: two code paths racing to author - // ~/.gitconfig is how the last silent displacement got in. - if p.Identity.Pinned("git") || p.Identity.Pinned("ssh") { + // The git config is GENERATED from the identity.git, identity.ssh and + // identity.gpg pins, and only from them. One writer, one path: two code + // paths racing to author ~/.gitconfig is how the last silent displacement + // got in. + // + // A gpg pin counts only with the sign scope: a decrypt-only key has nothing + // to say to git, and authoring ~/.gitconfig for it would displace a user's + // own git config grant on behalf of a key unrelated to git. + if p.Identity.Pinned("git") || p.Identity.Pinned("ssh") || p.Identity.gpgSigns() { if cfg := GitConfigFrom(p.Identity, home); len(cfg) > 0 { p.Replace(Mount{ Guest: home + "/.gitconfig", Kind: KindData, Access: AccessRO, - Content: cfg, From: []string{p.IdentityProvenance("git", "ssh")}, + Content: cfg, From: []string{p.IdentityProvenance("git", "ssh", "gpg")}, }) // GIT_CONFIG_GLOBAL REPLACES the global config; without it git reads // ~/.gitconfig AND $XDG_CONFIG_HOME/git/config and merges them, so @@ -892,6 +930,10 @@ func Resolve(reg map[ProfileName]*Profile, selected []ProfileName, ctx Context, } } + if err := resolveGPGHome(p, home); err != nil { + return nil, err + } + // The system-wide ssh_config is replaced on EVERY run, identity pinned or // not — wherever the deepest covering grant actually supplies the host's // copy at that path. It is not gated on p.Identity: the ownership refusal @@ -2003,6 +2045,38 @@ func refuseSigningWithoutAgent(p *Policy) error { git, p.IdentityOwner("ssh")) } +// refuseTwoSigningKeys refuses a profile that pins BOTH an ssh signing key and +// a sign-scoped gpg key. +// +// Both arms author `commit.gpgsign = true` in the sandbox's ~/.gitconfig and +// both write `user.signingkey` — but `gpg.format` is ONE value, so git would +// use exactly one of the two keys and the other pin would be a grant that +// silently does nothing. Refusing names both fields, which is the fix; picking +// a winner here would be a rule a reader has to simulate. +// +// There is no analogous conflict for a DECRYPT-only gpg pin: it authors no +// signing config, so it composes freely with an ssh signing key. +func refuseTwoSigningKeys(p *Policy) error { + id := p.Identity + if id == nil { + return nil + } + sshSigning := id.Git.SigningKey != "" && id.SSH.Agent != SSHNone + gpgSigning := id.GPG.Fingerprint != "" && GPGScopeHas(id.GPG.Scope, GPGScopeSign) + if !sshSigning || !gpgSigning { + return nil + } + who := fmt.Sprintf("profiles %q and %q", p.IdentityOwner("git"), p.IdentityOwner("gpg")) + if p.IdentityOwner("git") == p.IdentityOwner("gpg") { + who = fmt.Sprintf("profile %q", p.IdentityOwner("git")) + } + return fmt.Errorf("%s: identity.git.signing_key and a sign-scoped "+ + "identity.gpg both turn on git commit signing, but git signs with ONE format "+ + "(gpg.format = ssh or openpgp), so one of the two keys would be pinned and never "+ + "used. Keep one: drop identity.git.signing_key, or drop \"sign\" from identity.gpg.scope", + who) +} + func refuseHalfNamedHost(p *Policy) error { id := p.Identity if id == nil { @@ -2085,3 +2159,38 @@ func visibleErr(err error) string { } return VisibleText(err.Error()) } + +// resolveGPGHome gives identity.gpg its own ~/.gnupg: a 0700 tmpfs that the CLI +// later fills with the proxy socket and the pinned key's public keyring, and +// GNUPGHOME pointing at it. The mode is the reason it is a tmpfs of its own +// rather than the 0755 anchor InstallAnchors would put there: gpg checks its +// home's mode and printed "WARNING: unsafe permissions on homedir" on every +// call under the anchor. +// +// A profile grant AT ~/.gnupg is refused rather than replaced. The tmpfs would +// hide the whole subtree the grant exposes — subtraction, which Replace's +// KindData exemption (same path, different content) does not cover — and the +// grant it would hide is the host keyring and possibly the unfiltered agent +// socket, the very hole this block exists to narrow. Deeper grants +// (~/.gnupg/gpg.conf) mount after the tmpfs and stay visible; a profile's own +// tmpfs there hides nothing. +func resolveGPGHome(p *Policy, home string) error { + if !p.Identity.Pinned("gpg") { + return nil + } + guest := home + "/" + GPGHomeGuest + if old, ok := p.Mounts[guest]; ok && old.Kind != KindTmpfs { + return fmt.Errorf("%s grants %s, and identity.gpg (profile %q) gives the sandbox a "+ + "generated ~/.gnupg whose S.gpg-agent is a one-key filtering proxy. The two cannot "+ + "both hold: the host's ~/.gnupg is every private key and possibly the unfiltered "+ + "agent socket.\n Drop the %s grant, or grant single PUBLIC files under it", + provenance(old), guest, p.IdentityOwner("gpg"), guest) + } + perm := uint32(0o700) + p.Replace(Mount{ + Guest: guest, Kind: KindTmpfs, Access: AccessRW, Perms: &perm, + From: []string{p.IdentityProvenance("gpg")}, + }) + p.AuthorEnv("GNUPGHOME", guest) + return nil +} diff --git a/internal/policy/resolve_test.go b/internal/policy/resolve_test.go index 8b7571bf..f2b4a8f6 100644 --- a/internal/policy/resolve_test.go +++ b/internal/policy/resolve_test.go @@ -754,7 +754,8 @@ func maximalProfile() *Profile { SSH: IdentitySSH{Host: "ssh.example", Key: "/home/u/.ssh/id.pub", Agent: SSHAgentProxy}, Git: IdentityGit{Name: "Some One", Email: "some.one@example.com", SigningKey: "/home/u/.ssh/sign.pub"}, - Gh: IdentityGh{Host: "gh.example", User: "some-one"}, + GPG: IdentityGPG{Fingerprint: "6732361300BBFDAE7F22972CD4381E1CE2880244", Scope: "decrypt"}, + Gh: IdentityGh{Host: "gh.example", User: "some-one"}, // NOT the default path, so dropping identity.kubectl.kubeconfig really // changes which file is read (the fixture serves both). Kube: IdentityKube{Context: "aks-dev", Credential: KubeCredentialClientCert, @@ -810,6 +811,11 @@ func keyDrops() []keyDrop { {"identity.git.name", "Identity.Git.Name", func(p *Profile) { p.Identity.Git.Name = "" }}, {"identity.git.email", "Identity.Git.Email", func(p *Profile) { p.Identity.Git.Email = "" }}, {"identity.git.signing_key", "Identity.Git.SigningKey", func(p *Profile) { p.Identity.Git.SigningKey = "" }}, + // Dropping either gpg leaf alone leaves a HALF block, which Resolve + // refuses; the drop is here so that refusal is exercised, and so a + // gpg leaf that stopped being checked would show up as a widening. + {"identity.gpg.fingerprint", "Identity.GPG.Fingerprint", func(p *Profile) { p.Identity.GPG.Fingerprint = "" }}, + {"identity.gpg.scope", "Identity.GPG.Scope", func(p *Profile) { p.Identity.GPG.Scope = "" }}, {"identity.gh.host", "Identity.Gh.Host", func(p *Profile) { p.Identity.Gh.Host = "" }}, {"identity.gh.user", "Identity.Gh.User", func(p *Profile) { p.Identity.Gh.User = "" }}, {"identity.kubectl.context", "Identity.Kube.Context", func(p *Profile) { p.Identity.Kube.Context = "" }}, @@ -820,6 +826,7 @@ func keyDrops() []keyDrop { // block removes one pin, and must widen nothing the others grant. {"identity.ssh", "Identity.SSH", func(p *Profile) { p.Identity.SSH = IdentitySSH{} }}, {"identity.git", "Identity.Git", func(p *Profile) { p.Identity.Git = IdentityGit{} }}, + {"identity.gpg", "Identity.GPG", func(p *Profile) { p.Identity.GPG = IdentityGPG{} }}, {"identity.gh", "Identity.Gh", func(p *Profile) { p.Identity.Gh = IdentityGh{} }}, {"identity.kubectl", "Identity.Kube", func(p *Profile) { p.Identity.Kube = IdentityKube{} }}, {"environ.set", "Environ.Set", func(p *Profile) { p.Environ.Set = nil }}, diff --git a/internal/policy/testdata/refusals.txt b/internal/policy/testdata/refusals.txt index 5af37b8c..ce497b41 100644 --- a/internal/policy/testdata/refusals.txt +++ b/internal/policy/testdata/refusals.txt @@ -68,7 +68,7 @@ profile gitconf puts a file the profile writes at /etc/gitconfig, which is insid profile "conf": file /etc/gitconfig, line 1: {profile.id.identity.ssh.host} names "id", which is neither "conf" nor a profile it includes, so the text would depend on what else the run selects. Add include = ["id"] to "conf", or write the value. == profile_file_unknown_placeholder == -profile "conf": file /etc/gitconfig, line 2: unknown placeholder {homee}: the placeholders are {home}, {lbrace}, {rbrace}, {target_parent}, {target} and {profile.NAME.KEY} with KEY one of identity.gh.host, identity.gh.user, identity.git.email, identity.git.name, identity.kubectl.context, identity.kubectl.credential, identity.kubectl.server, identity.ssh.agent, identity.ssh.host +profile "conf": file /etc/gitconfig, line 2: unknown placeholder {homee}: the placeholders are {home}, {lbrace}, {rbrace}, {target_parent}, {target} and {profile.NAME.KEY} with KEY one of identity.gh.host, identity.gh.user, identity.git.email, identity.git.name, identity.gpg.fingerprint, identity.gpg.scope, identity.kubectl.context, identity.kubectl.credential, identity.kubectl.server, identity.ssh.agent, identity.ssh.host == profile_file_displaced_by_a_generated_file == profile conf writes a file at /home/u/.gitconfig, but snug generates that file for this selection (from identity:id+replaces:conf). A profile file never replaces a generated one. write path = "/etc/gitconfig": git reads that system-scope file below the generated global one, and snug's pins (user, signing, insteadOf, safe.directory) still win there. diff --git a/internal/policy/types.go b/internal/policy/types.go index cdc74922..00bc499b 100644 --- a/internal/policy/types.go +++ b/internal/policy/types.go @@ -145,7 +145,8 @@ type Mount struct { Access Access Optional bool // -try semantics: silently skip when Host is absent - // Perms is the mode for a KindData file. nil means bwrap's default. + // Perms is the mode for a KindData file or a KindTmpfs directory. nil + // means bwrap's default (0755 for a tmpfs). Perms *uint32 // Content is the file body for KindData. It is materialised into an diff --git a/internal/profile/file.go b/internal/profile/file.go index 916d8312..da7817ac 100644 --- a/internal/profile/file.go +++ b/internal/profile/file.go @@ -141,6 +141,7 @@ type rawEnviron struct { type rawIdentity struct { SSH rawIdentitySSH `toml:"ssh"` Git rawIdentityGit `toml:"git"` + GPG rawIdentityGPG `toml:"gpg"` Gh rawIdentityGh `toml:"gh"` Kube rawIdentityKube `toml:"kubectl"` } @@ -164,6 +165,14 @@ type rawIdentityGit struct { SigningKey string `toml:"signing_key"` } +// rawIdentityGPG is [identity.gpg]. Scope is a []string here and a canonical +// comma-joined string in policy.Identity: the array is what a profile author +// writes, and toIdentity folds it into the comparable leaf that Identity needs. +type rawIdentityGPG struct { + Fingerprint string `toml:"fingerprint"` + Scope []string `toml:"scope"` +} + type rawIdentityGh struct { Host string `toml:"host"` User string `toml:"user"` @@ -613,6 +622,10 @@ func toIdentity(r *rawIdentity, name, source string) (*policy.Identity, error) { if _, err := policy.ParseKubeCredential(r.Kube.Credential); err != nil { return nil, fmt.Errorf("%s: profile %q: %w", source, name, err) } + gpgFingerprint, gpgScope, err := gpgBlock(r.GPG, name, source) + if err != nil { + return nil, err + } id := &policy.Identity{ SSH: policy.IdentitySSH{ Host: r.SSH.Host, @@ -624,6 +637,10 @@ func toIdentity(r *rawIdentity, name, source string) (*policy.Identity, error) { Email: r.Git.Email, SigningKey: r.Git.SigningKey, }, + GPG: policy.IdentityGPG{ + Fingerprint: gpgFingerprint, + Scope: gpgScope, + }, Gh: policy.IdentityGh{ Host: r.Gh.Host, User: r.Gh.User, @@ -641,6 +658,41 @@ func toIdentity(r *rawIdentity, name, source string) (*policy.Identity, error) { return id, nil } +// gpgBlock converts [identity.gpg]'s two leaves, and refuses a HALF block. +// +// An absent block (both leaves unset) returns two empty strings and takes no +// part in the join, exactly like an absent [identity.ssh]. A block that sets +// ONE of the two is refused HERE rather than left to Resolve, because the +// fingerprint is a property of the profile TEXT and `snug profile show` — which +// never resolves — must render the fault on the screen a human reads to decide +// whether to select the profile. A gpg block naming a fingerprint but no scope +// would otherwise sit on that screen looking complete while granting nothing, +// which is the same failure #454's branch fixed for identity.ssh.agent. +func gpgBlock(r rawIdentityGPG, name, source string) (fingerprint, scope string, err error) { + if r.Fingerprint == "" && len(r.Scope) == 0 { + return "", "", nil + } + if r.Fingerprint == "" { + return "", "", fmt.Errorf("%s: profile %q: identity.gpg names a scope but no "+ + "fingerprint.\n Name the key: `fingerprint = \"...\"`, the string "+ + "`gpg --fingerprint` prints", source, name) + } + fingerprint, err = policy.ParseFingerprint(r.Fingerprint) + if err != nil { + return "", "", fmt.Errorf("%s: profile %q: %w", source, name, err) + } + if len(r.Scope) == 0 { + return "", "", fmt.Errorf("%s: profile %q: identity.gpg pins a key but names no "+ + "scope.\n A key with no scope grants nothing; say what the sandbox may "+ + "do with it: scope = [\"sign\"], [\"decrypt\"] or both", source, name) + } + scope, err = policy.ParseGPGRuntimeScope(r.Scope) + if err != nil { + return "", "", fmt.Errorf("%s: profile %q: %w", source, name, err) + } + return fingerprint, scope, nil +} + // emptyIdentityBlock refuses [identity] with no keys under it. // // An empty block pins nothing — Resolve joins identity one block at a time and @@ -652,8 +704,8 @@ func emptyIdentityBlock(source, name string) error { return fmt.Errorf("%s: profile %q has an [identity] block that sets nothing.\n"+ " It pins nothing, and a reader of this file would believe it pins something;\n"+ " the likelier cause is keys written under the wrong table. Write at least one\n"+ - " key under [identity.ssh], [identity.git], [identity.gh] or [identity.kubectl],\n"+ - " or remove the block", + " key under [identity.ssh], [identity.git], [identity.gpg], [identity.gh] or\n"+ + " [identity.kubectl], or remove the block", source, name) } diff --git a/internal/profile/identitygpg_test.go b/internal/profile/identitygpg_test.go new file mode 100644 index 00000000..1d053f96 --- /dev/null +++ b/internal/profile/identitygpg_test.go @@ -0,0 +1,148 @@ +package profile + +import ( + "strconv" + "strings" + "testing" + + "github.com/gomoni/snug/internal/policy" +) + +// [identity.gpg] is two leaves: a fingerprint and a scope array. The block +// passes through the same two doors as identity.ssh and identity.kubectl, and +// the two HALF shapes — a fingerprint with no scope, a scope with no +// fingerprint — are refused at PARSE time, because `snug profile show` renders a +// *policy.Profile straight from the registry and never resolves. A half block +// that parsed would sit on that screen looking complete while granting nothing, +// the same failure TestUnknownAgentModeIsRefusedAtParseTime describes for +// identity.ssh.agent. + +func gpgProfile(fingerprint, scope string) []byte { + body := "[profile.x]\n[profile.x.identity.gpg]\n" + if fingerprint != "" { + body += "fingerprint = " + strconv.Quote(fingerprint) + "\n" + } + if scope != "" { + body += "scope = [" + scope + "]\n" + } + return []byte(body) +} + +const gpgFingerprint = "6732361300BBFDAE7F22972CD4381E1CE2880244" + +func TestIdentityGPGBlockParsesAndCanonicalises(t *testing.T) { + cases := []struct { + name, scope, wantScope string + }{ + {"sign only", `"sign"`, "sign"}, + {"decrypt only", `"decrypt"`, "decrypt"}, + // The array order is not the stored order: the stored value is the + // canonical, sorted, comma-joined set. + {"both, sign first", `"sign", "decrypt"`, "decrypt,sign"}, + {"both, decrypt first", `"decrypt", "sign"`, "decrypt,sign"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + reg, err := parse(gpgProfile(gpgFingerprint, c.scope), "mine.toml", true) + if err != nil { + t.Fatalf("an accepted identity.gpg block was refused: %v", err) + } + id := reg["x"].Identity + if id == nil { + t.Fatal("Identity is nil for a block that set identity.gpg") + } + if id.GPG.Fingerprint != gpgFingerprint { + t.Errorf("fingerprint = %q, want %q", id.GPG.Fingerprint, gpgFingerprint) + } + if id.GPG.Scope != c.wantScope { + t.Errorf("scope = %q, want the canonical %q", id.GPG.Scope, c.wantScope) + } + }) + } +} + +func TestIdentityGPGLowercaseFingerprintIsNormalised(t *testing.T) { + reg, err := parse(gpgProfile("6732361300bbfdae7f22972cd4381e1ce2880244", `"sign"`), "mine.toml", true) + if err != nil { + t.Fatalf("a lowercase fingerprint was refused: %v", err) + } + if got := reg["x"].Identity.GPG.Fingerprint; got != gpgFingerprint { + t.Errorf("fingerprint = %q, want it uppercased to %q", got, gpgFingerprint) + } +} + +func TestIdentityGPGHalfBlockIsRefused(t *testing.T) { + cases := []struct { + name, fingerprint, scope, wants string + }{ + {"scope with no fingerprint", "", `"sign"`, "names a scope but no fingerprint"}, + {"fingerprint with no scope", gpgFingerprint, "", "names no scope"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := parse(gpgProfile(c.fingerprint, c.scope), "mine.toml", true) + if err == nil { + t.Fatalf("a half [identity.gpg] block parsed; `snug profile show` would "+ + "render it as a key %q with the other leaf unset", c.name) + } + for _, want := range []string{"mine.toml", `"x"`, c.wants} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q: %v", want, err) + } + } + }) + } +} + +func TestIdentityGPGBadValuesAreRefusedAtParseTime(t *testing.T) { + cases := []struct { + name, fingerprint, scope, wants string + }{ + {"fingerprint too short", "6732361300BBFDAE", `"sign"`, "has 16 characters"}, + {"fingerprint not hex", "6732361300BBFDAE7F22972CD4381E1CE2880ZZ4", `"sign"`, "not hexadecimal"}, + {"fingerprint is a path", "/home/u/.gnupg/pubkey", `"sign"`, "has 21 characters"}, + {"scope unknown", gpgFingerprint, `"encrypt"`, "unknown identity.gpg.scope"}, + {"scope auth", gpgFingerprint, `"auth"`, "unknown identity.gpg.scope"}, + {"scope empty", gpgFingerprint, "", "names no scope"}, + {"scope repeated", gpgFingerprint, `"sign", "sign"`, "names \"sign\" twice"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := parse(gpgProfile(c.fingerprint, c.scope), "mine.toml", true) + if err == nil { + t.Fatalf("identity.gpg with %s parsed", c.name) + } + for _, want := range []string{"mine.toml", c.wants} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q: %v", want, err) + } + } + }) + } + // "encrypt" is the value a reader most expects to exist — gpg encrypts to + // the key — so the refusal has to name the accepted set, which is the fix. + _, err := parse(gpgProfile(gpgFingerprint, `"encrypt"`), "mine.toml", true) + for _, want := range []string{"sign", "decrypt"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the unknown-scope refusal does not name %q: %v", want, err) + } + } +} + +// A block that sets NEITHER leaf is not a block: an absent [identity.gpg] takes +// no part in the join, exactly like an absent [identity.ssh]. The positive +// control for the half-block refusal above. +func TestAbsentIdentityGPGBlockIsNoGrant(t *testing.T) { + reg, err := parse([]byte("[profile.x]\n[profile.x.identity.git]\nname = \"Some One\"\n"), + "mine.toml", true) + if err != nil { + t.Fatal(err) + } + if got := reg["x"].Identity.GPG; got != (policy.IdentityGPG{}) { + t.Errorf("an absent gpg block stored %+v, want the zero block", got) + } + // And it takes no part in the pin: Pinned("gpg") is false. + if reg["x"].Identity.Pinned("gpg") { + t.Error("an absent gpg block reported itself pinned") + } +} diff --git a/internal/profile/identitynested_test.go b/internal/profile/identitynested_test.go index 3501119e..dd728760 100644 --- a/internal/profile/identitynested_test.go +++ b/internal/profile/identitynested_test.go @@ -106,7 +106,7 @@ func TestIdentityFieldKeysMatchTheProfileTOMLTags(t *testing.T) { for i := 0; i < raw.NumField(); i++ { f := raw.Field(i) switch f.Name { - case "SSH", "Git", "Gh", "Kube": + case "SSH", "Git", "GPG", "Gh", "Kube": prefix := f.Tag.Get("toml") + "." nested := f.Type for j := 0; j < nested.NumField(); j++ { diff --git a/internal/sshproxy/proxy.go b/internal/sshproxy/proxy.go index 7dc5522d..106b53cb 100644 --- a/internal/sshproxy/proxy.go +++ b/internal/sshproxy/proxy.go @@ -42,10 +42,11 @@ import ( "net" "os" "strings" - "sync" "time" "github.com/gomoni/snug/internal/hostread" + + "github.com/gomoni/snug/internal/unixserve" ) // Agent protocol message numbers (draft-miller-ssh-agent). @@ -119,16 +120,9 @@ type Proxy struct { pinned []pin upstream string // the host's real SSH_AUTH_SOCK - ln net.Listener audit func(string) - // mu orders Serve's wg.Add against Close's wg.Wait: an Add that can run - // concurrently with a Wait on a zero counter is a misuse of WaitGroup, and - // the race detector reports it. Once closed is set, Serve adds nothing. - mu sync.Mutex - closed bool - wg sync.WaitGroup - once sync.Once + srv *unixserve.Server } // New parses the PUBLIC key files and prepares a proxy pinned to all of them. @@ -214,40 +208,13 @@ func New(keys []PinnedKey, upstream, socketPath string, audit func(string)) (*Pr if audit == nil { audit = func(string) {} } - return &Proxy{pinned: pins, upstream: upstream, ln: ln, audit: audit}, nil + return &Proxy{pinned: pins, upstream: upstream, srv: unixserve.New(ln), audit: audit}, nil } -func (p *Proxy) Serve() { - for { - c, err := p.ln.Accept() - if err != nil { - return // listener closed - } - p.mu.Lock() - if p.closed { - p.mu.Unlock() - c.Close() - return - } - p.wg.Add(1) - p.mu.Unlock() - go func() { - defer p.wg.Done() - defer c.Close() - p.handle(c) - }() - } -} +func (p *Proxy) Serve() { p.srv.Serve(p.handle) } -func (p *Proxy) Close() { - p.once.Do(func() { - p.mu.Lock() - p.closed = true - p.mu.Unlock() - p.ln.Close() - p.wg.Wait() - }) -} +// Close stops the proxy and ends every live sandbox connection. +func (p *Proxy) Close() { p.srv.Close() } // handle serves one client connection. Anything it does not positively // understand is answered with SSH_AGENT_FAILURE — the protocol's own way of diff --git a/internal/unixserve/serve.go b/internal/unixserve/serve.go new file mode 100644 index 00000000..dc3818d8 --- /dev/null +++ b/internal/unixserve/serve.go @@ -0,0 +1,75 @@ +// Package unixserve is the accept loop the agent proxies share: one goroutine +// per connection, and a Close that ends them. +// +// Close ends live connections rather than waiting for them. A handler blocks +// reading its client with no deadline — the client is a process in the sandbox +// and may sit idle for as long as the run lasts — so a Close that only waited +// would hang snug's teardown on any client that connected and stayed silent. +package unixserve + +import ( + "net" + "sync" +) + +type Server struct { + ln net.Listener + + // mu orders Serve's wg.Add against Close's wg.Wait: an Add that can run + // concurrently with a Wait on a zero counter is a misuse of WaitGroup, and + // the race detector reports it. Once closed is set, Serve adds nothing. + mu sync.Mutex + closed bool + wg sync.WaitGroup + once sync.Once + conns map[net.Conn]struct{} +} + +func New(ln net.Listener) *Server { + return &Server{ln: ln, conns: map[net.Conn]struct{}{}} +} + +// Serve accepts until the listener is closed, running handle for each +// connection on its own goroutine and closing the connection when it returns. +func (s *Server) Serve(handle func(net.Conn)) { + for { + c, err := s.ln.Accept() + if err != nil { + return // listener closed + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + c.Close() + return + } + s.wg.Add(1) + s.conns[c] = struct{}{} + s.mu.Unlock() + go func() { + defer s.wg.Done() + defer func() { + s.mu.Lock() + delete(s.conns, c) + s.mu.Unlock() + c.Close() + }() + handle(c) + }() + } +} + +// Close stops accepting, closes every live connection, and waits for their +// handlers to return. +func (s *Server) Close() { + s.once.Do(func() { + s.mu.Lock() + s.closed = true + for c := range s.conns { + c.Close() + } + s.mu.Unlock() + s.ln.Close() + s.wg.Wait() + }) +} diff --git a/internal/unixserve/serve_test.go b/internal/unixserve/serve_test.go new file mode 100644 index 00000000..ba43d809 --- /dev/null +++ b/internal/unixserve/serve_test.go @@ -0,0 +1,41 @@ +package unixserve + +import ( + "net" + "path/filepath" + "testing" + "time" +) + +// TestCloseEndsASilentClient: a client that connects and never writes holds its +// handler in a read with no deadline. Close must end that connection rather +// than wait for it, or snug's teardown hangs for as long as the sandbox +// process keeps the socket open. +func TestCloseEndsASilentClient(t *testing.T) { + path := filepath.Join(t.TempDir(), "s") + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + s := New(ln) + entered := make(chan struct{}) + go s.Serve(func(c net.Conn) { + close(entered) + buf := make([]byte, 1) + c.Read(buf) // blocks until Close ends the connection + }) + client, err := net.Dial("unix", path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + <-entered + + done := make(chan struct{}) + go func() { s.Close(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Close did not return while a silent client was connected") + } +} diff --git a/test/integration/identitygpg_test.go b/test/integration/identitygpg_test.go new file mode 100644 index 00000000..49ab4b93 --- /dev/null +++ b/test/integration/identitygpg_test.go @@ -0,0 +1,480 @@ +//go:build integration + +package integration + +// identity.gpg, end to end: a real gpg-agent in a throwaway GNUPGHOME, a real +// sandbox, and the filtering proxy between them. The positive tests prove the +// grant works (a signed commit verifies, a decryption succeeds); the rest are +// the negatives, each paired with a control that the HOST can do the thing the +// sandbox must not: another key, the host's own agent socket, the secret keys. +// +// Every gpg call here uses the throwaway home. ~/.gnupg is never read, and +// the agents started here are killed on cleanup. + +import ( + "bytes" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// gpgHost is the throwaway host: one agent, a pinned key (ed25519 primary, +// ed25519 signing subkey, cv25519 encryption subkey) and a second key the +// profiles never pin. +type gpgHost struct { + home string + fpr, otherFpr string + otherGrips []string + agentSocket string + env []string // snug's environment: the throwaway GNUPGHOME and a private runtime dir + xdgRuntime string + t *testing.T +} + +func requireGPG(t *testing.T) { + t.Helper() + requireSandbox(t) + for _, bin := range []string{"gpg", "gpgconf", "gpg-agent", "gpg-connect-agent", "git"} { + if _, err := exec.LookPath(bin); err != nil { + skipOrFail(t, "%s is not installed; identity.gpg has nothing to run against", bin) + } + } +} + +func (h *gpgHost) gpg(args ...string) string { + h.t.Helper() + out, errs, err := h.gpgRun(nil, args...) + if err != nil { + h.t.Fatalf("gpg %v: %v\n%s", args, err, errs) + } + return out +} + +func (h *gpgHost) gpgRun(stdin []byte, args ...string) (string, string, error) { + h.t.Helper() + cmd := exec.Command("gpg", append([]string{"--homedir", h.home, "--batch", + "--pinentry-mode", "loopback", "--passphrase", ""}, args...)...) + cmd.Env = append(os.Environ(), "GNUPGHOME="+h.home, "LC_ALL=C") + cmd.Stdin = bytes.NewReader(stdin) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + return out.String(), errb.String(), err +} + +func (h *gpgHost) fprOf(uid string) string { + h.t.Helper() + for line := range strings.SplitSeq(h.gpg("--with-colons", "--list-keys", "--", uid), "\n") { + if f := strings.Split(line, ":"); f[0] == "fpr" && len(f) > 9 { + return f[9] + } + } + h.t.Fatalf("no fingerprint for %q", uid) + return "" +} + +// gripsOf is every keygrip of a key, primary included, composite split. +func (h *gpgHost) gripsOf(fpr string) []string { + var out []string + for line := range strings.SplitSeq(h.gpg("--with-colons", "--with-keygrip", "--list-secret-keys", "--", fpr), "\n") { + if f := strings.Split(line, ":"); f[0] == "grp" && len(f) > 9 { + out = append(out, strings.Split(f[9], ",")...) + } + } + if len(out) == 0 { + h.t.Fatalf("no keygrips for %s", fpr) + } + return out +} + +func newGPGHost(t *testing.T) *gpgHost { + t.Helper() + requireGPG(t) + // Short, for the agent socket's sun_path and the runtime dir's. + home, err := os.MkdirTemp("", "gg") + if err != nil { + t.Fatal(err) + } + xdg, err := os.MkdirTemp("", "gx") + if err != nil { + t.Fatal(err) + } + h := &gpgHost{home: home, t: t, xdgRuntime: xdg} + t.Cleanup(func() { + for _, a := range [][]string{{"--kill", "gpg-agent"}, {"--remove-socketdir"}} { + c := exec.Command("gpgconf", append([]string{"--homedir", home}, a...)...) + c.Env = append(os.Environ(), "GNUPGHOME="+home) + _ = c.Run() + } + os.RemoveAll(home) + os.RemoveAll(xdg) + }) + h.gpg("--quick-gen-key", "Pinned ", "ed25519", "cert", "never") + h.fpr = h.fprOf("pin@example.org") + h.gpg("--quick-add-key", h.fpr, "ed25519", "sign", "never") + h.gpg("--quick-add-key", h.fpr, "cv25519", "encr", "never") + h.gpg("--quick-gen-key", "Other ", "ed25519", "cert", "never") + h.otherFpr = h.fprOf("other@example.org") + h.gpg("--quick-add-key", h.otherFpr, "ed25519", "sign", "never") + h.gpg("--quick-add-key", h.otherFpr, "cv25519", "encr", "never") + h.otherGrips = h.gripsOf(h.otherFpr) + + cmd := exec.Command("gpgconf", "--homedir", home, "--list-dirs", "agent-socket") + cmd.Env = append(os.Environ(), "GNUPGHOME="+home) + out, err := cmd.Output() + if err != nil { + t.Fatalf("gpgconf --list-dirs agent-socket: %v", err) + } + h.agentSocket = strings.TrimSpace(string(out)) + if _, err := os.Stat(h.agentSocket); err != nil { + t.Fatalf("control: the throwaway agent has no socket at %s: %v", h.agentSocket, err) + } + h.env = []string{"GNUPGHOME=" + home, "XDG_RUNTIME_DIR=" + xdg} + return h +} + +// profile writes a profile pinning fpr with the given scope and returns the +// environment to run snug with. +func (h *gpgHost) profile(fpr string, scope ...string) []string { + h.t.Helper() + quoted := make([]string, len(scope)) + for i, s := range scope { + quoted[i] = `"` + s + `"` + } + return writeProfile(h.t, "[profile.gpgid]\n"+ + "description = \"one throwaway OpenPGP key\"\n"+ + "[profile.gpgid.identity.gpg]\n"+ + "fingerprint = \""+fpr+"\"\n"+ + "scope = ["+strings.Join(quoted, ", ")+"]\n", h.env...) +} + +// profileEnv is profile for the key and both scopes, for callers that start a +// background sandbox. +func (h *gpgHost) profileEnv() []string { return h.profile(h.fpr, "sign", "decrypt") } + +const gitIdent = `export GIT_AUTHOR_NAME=a GIT_AUTHOR_EMAIL=a@example.org GIT_COMMITTER_NAME=a GIT_COMMITTER_EMAIL=a@example.org +` + +// TestIdentityGPGSignsACommitAsTheSubkey: with scope sign, git inside signs a +// commit through the proxy and the signature verifies against the staged +// public keyring, made by a SUBKEY of the pinned primary. The key's private +// half exists only in the host agent (a positive control that the signing +// worked at all), and the sandbox's GNUPGHOME is a 0700 directory that gpg +// accepts without the "unsafe permissions" warning. +func TestIdentityGPGSignsACommitAsTheSubkey(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + r := runEnv(t, h.profile(h.fpr, "sign"), []string{"-p", "gpgid"}, proj, gitIdent+` +git init -q . +git commit --allow-empty -q -m signed +echo "commit-exit=[$?]" +echo "STATUS-BEGIN"; git log -1 --format='%G?'; echo "STATUS-END" +echo "PRIMARY-BEGIN"; git log -1 --format='%GP'; echo "PRIMARY-END" +echo "SIGNER-BEGIN"; git log -1 --format='%GF'; echo "SIGNER-END" +echo "HOMEMODE=[$(stat -c %a "$GNUPGHOME")]" +gpg --list-keys 2>&1 | grep -c 'unsafe permissions' | sed 's/^/unsafe=/' +`).mustRun(t) + if !strings.Contains(r.out, "commit-exit=[0]") { + t.Fatalf("git commit did not sign inside the sandbox:\n%s", r.out) + } + status, _ := section(r.out, "STATUS") + // G is good and trusted, U good with unknown validity: the staged keyring + // carries no ownertrust, and either way the signature checked out. + if s := strings.TrimSpace(status); s != "G" && s != "U" { + t.Errorf("%%G? = %q, want a good signature (G or U):\n%s", s, r.out) + } + if primary, _ := section(r.out, "PRIMARY"); strings.TrimSpace(primary) != h.fpr { + t.Errorf("the signature's primary key is %q, want the pinned %s", strings.TrimSpace(primary), h.fpr) + } + if signer, _ := section(r.out, "SIGNER"); strings.TrimSpace(signer) == h.fpr || strings.TrimSpace(signer) == "" { + t.Errorf("the signing key is %q: it must be a SUBKEY, the primary is never pinned", strings.TrimSpace(signer)) + } + if !strings.Contains(r.out, "HOMEMODE=[700]") { + t.Errorf("GNUPGHOME inside is not mode 700:\n%s", r.out) + } + if !strings.Contains(r.out, "unsafe=0") { + t.Errorf("gpg warned about unsafe permissions on its homedir:\n%s", r.out) + } +} + +// TestIdentityGPGDecryptsOnlyWhatIsAddressedToThePin: scope decrypt decrypts a +// file encrypted to the pinned key and fails on one encrypted to a key the +// profile did not pin. The host decrypts BOTH (the controls), so the second +// failure is the proxy's and not a broken message; the plaintext marker in the +// output is what a decryption looks like, so its absence is not an empty run. +func TestIdentityGPGDecryptsOnlyWhatIsAddressedToThePin(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + plain := filepath.Join(proj, "plain.txt") + if err := os.WriteFile(plain, []byte("the-decrypted-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + toPin, toOther := filepath.Join(proj, "to-pin.gpg"), filepath.Join(proj, "to-other.gpg") + h.gpg("--trust-model", "always", "-r", h.fpr, "-o", toPin, "-e", plain) + h.gpg("--trust-model", "always", "-r", h.otherFpr, "-o", toOther, "-e", plain) + for _, f := range []string{toPin, toOther} { + if out := h.gpg("-d", f); out != "the-decrypted-secret\n" { + t.Fatalf("control: the host cannot decrypt %s (got %q)", f, out) + } + } + os.Remove(plain) + + r := runEnv(t, h.profile(h.fpr, "decrypt"), []string{"-p", "gpgid"}, proj, ` +echo "PIN-BEGIN"; gpg --batch -d to-pin.gpg 2>&1; echo "exit=[$?]"; echo "PIN-END" +echo "OTHER-BEGIN"; gpg --batch -d to-other.gpg 2>&1; echo "exit=[$?]"; echo "OTHER-END" +echo "SIGN-BEGIN"; echo hi | gpg --batch -u `+h.fpr+` --clearsign 2>&1; echo "exit=[$?]"; echo "SIGN-END" +test -e ~/.gitconfig && echo "gitconfig=present" || echo "gitconfig=absent" +`).mustRun(t) + pin, _ := section(r.out, "PIN") + if !strings.Contains(pin, "the-decrypted-secret") { + t.Errorf("the pinned key's message was not decrypted:\n%s", r.out) + } + other, _ := section(r.out, "OTHER") + if strings.Contains(other, "the-decrypted-secret") || strings.Contains(other, "exit=[0]") { + t.Errorf("a message addressed to an unpinned key was decrypted:\n%s", other) + } + sign, _ := section(r.out, "SIGN") + if strings.Contains(sign, "BEGIN PGP SIGNED MESSAGE") || strings.Contains(sign, "exit=[0]") { + t.Errorf("a decrypt-only scope signed:\n%s", sign) + } + if !strings.Contains(r.out, "gitconfig=absent") { + t.Errorf("a decrypt-only pin authored ~/.gitconfig:\n%s", r.out) + } +} + +// TestIdentityGPGSignScopeCannotDecrypt is the mirror: a sign-only profile +// does not become a decryption oracle for the same key. +func TestIdentityGPGSignScopeCannotDecrypt(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + plain := filepath.Join(proj, "plain.txt") + os.WriteFile(plain, []byte("the-decrypted-secret\n"), 0o600) + toPin := filepath.Join(proj, "to-pin.gpg") + h.gpg("--trust-model", "always", "-r", h.fpr, "-o", toPin, "-e", plain) + if out := h.gpg("-d", toPin); out != "the-decrypted-secret\n" { + t.Fatalf("control: the host cannot decrypt (got %q)", out) + } + os.Remove(plain) + r := runEnv(t, h.profile(h.fpr, "sign"), []string{"-p", "gpgid"}, proj, ` +echo "DEC-BEGIN"; gpg --batch -d to-pin.gpg 2>&1; echo "exit=[$?]"; echo "DEC-END" +echo "SIG-BEGIN"; echo hi | gpg --batch -u `+h.fpr+` --clearsign 2>&1; echo "exit=[$?]"; echo "SIG-END" +`).mustRun(t) + sig, _ := section(r.out, "SIG") + if !strings.Contains(sig, "BEGIN PGP SIGNED MESSAGE") { + t.Fatalf("control: the sign scope did not sign:\n%s", r.out) + } + dec, _ := section(r.out, "DEC") + if strings.Contains(dec, "the-decrypted-secret") || strings.Contains(dec, "exit=[0]") { + t.Errorf("a sign-only scope decrypted:\n%s", dec) + } +} + +// TestIdentityGPGShowsOnlyThePinnedKey: inside, the agent lists the pinned +// key's grips and no others, gpg lists only the pinned key, the host's own +// agent socket and homedir are not visible, and no private key material is in +// the sandbox's GNUPGHOME. The controls are the host's: its agent does list +// the other key's grips, and its socket path does exist. +func TestIdentityGPGShowsOnlyThePinnedKey(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + + hostList, _ := exec.Command("gpg-connect-agent", "--homedir", h.home, "KEYINFO --list", "/bye").Output() + for _, g := range h.otherGrips { + if !strings.Contains(strings.ToUpper(string(hostList)), strings.ToUpper(g)) { + t.Fatalf("control: the host agent does not list the other key's grip %s:\n%s", g, hostList) + } + } + + r := runEnv(t, h.profile(h.fpr, "sign", "decrypt"), []string{"-p", "gpgid"}, proj, ` +echo "KEYINFO-BEGIN"; gpg-connect-agent "KEYINFO --list" /bye 2>&1; echo "KEYINFO-END" +echo "LIST-BEGIN"; gpg --batch --with-colons --list-secret-keys 2>&1; echo "LIST-END" +echo "LS-BEGIN"; ls -A "$GNUPGHOME"; echo "LS-END" +test -S "$GNUPGHOME/S.gpg-agent" && echo "proxy-socket=present" +test -e `+h.agentSocket+` && echo "host-agent-socket=VISIBLE" || echo "host-agent-socket=absent" +test -e `+h.home+` && echo "host-home=VISIBLE" || echo "host-home=absent" +`).mustRun(t) + + keyinfo, _ := section(r.out, "KEYINFO") + pinned := h.gripsOf(h.fpr) + if !strings.Contains(strings.ToUpper(keyinfo), strings.ToUpper(pinned[1])) { + t.Errorf("KEYINFO --list inside omits the pinned subkey grip %s:\n%s", pinned[1], keyinfo) + } + for _, g := range append(h.otherGrips, pinned[0]) { + if strings.Contains(strings.ToUpper(keyinfo), strings.ToUpper(g)) { + t.Errorf("KEYINFO --list inside shows %s, which is not pinned (the other key, or the primary):\n%s", g, keyinfo) + } + } + list, _ := section(r.out, "LIST") + if !strings.Contains(list, h.fpr) { + t.Errorf("gpg inside does not list the pinned key:\n%s", list) + } + if strings.Contains(list, h.otherFpr) { + t.Errorf("gpg inside lists the unpinned key %s:\n%s", h.otherFpr, list) + } + ls, _ := section(r.out, "LS") + for _, bad := range []string{"private-keys-v1.d", "secring", "openpgp-revocs.d"} { + if strings.Contains(ls, bad) { + t.Errorf("GNUPGHOME inside holds %s:\n%s", bad, ls) + } + } + for _, want := range []string{"proxy-socket=present", "host-agent-socket=absent", "host-home=absent"} { + if !strings.Contains(r.out, want) { + t.Errorf("missing %q:\n%s", want, r.out) + } + } +} + +// TestIdentityGPGCannotExportOrCertify: the primary key certifies and the +// agent holds the secret keys, so neither may be reachable. Inside, +// --export-secret-keys yields nothing and --quick-add-uid on the pinned key +// fails; afterwards the HOST key has the one user ID it started with. The +// control is the host adding a user ID to the other key, and the signing the +// sandbox does at the end of the run, so the failures are not a gpg that +// cannot reach the agent at all. +func TestIdentityGPGCannotExportOrCertify(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + uids := func(fpr string) int { + n := 0 + for line := range strings.SplitSeq(h.gpg("--with-colons", "--list-keys", "--", fpr), "\n") { + if strings.HasPrefix(line, "uid:") { + n++ + } + } + return n + } + before := uids(h.fpr) + h.gpg("--quick-add-uid", h.otherFpr, "Control ") + if uids(h.otherFpr) != 2 { + t.Fatal("control: the host could not add a user ID to the other key") + } + if out := h.gpg("--export-secret-keys", h.fpr); len(out) == 0 { + t.Fatal("control: the host exports no secret key either") + } + + r := runEnv(t, h.profile(h.fpr, "sign", "decrypt"), []string{"-p", "gpgid"}, proj, ` +echo "EXPORT-BEGIN"; gpg --batch --export-secret-keys `+h.fpr+` 2>/dev/null | wc -c; echo "EXPORT-END" +echo "ADDUID-BEGIN"; gpg --batch --quick-add-uid `+h.fpr+` 'Evil ' 2>&1; echo "exit=[$?]"; echo "ADDUID-END" +echo "SIGN-BEGIN"; echo hi | gpg --batch -u `+h.fpr+` --clearsign 2>&1 | head -1; echo "SIGN-END" +`).mustRun(t) + if exp, _ := section(r.out, "EXPORT"); strings.TrimSpace(exp) != "0" { + t.Errorf("--export-secret-keys inside returned %q bytes:\n%s", strings.TrimSpace(exp), r.out) + } + if add, _ := section(r.out, "ADDUID"); strings.Contains(add, "exit=[0]") { + t.Errorf("--quick-add-uid on the pinned key succeeded inside:\n%s", add) + } + if sign, _ := section(r.out, "SIGN"); !strings.Contains(sign, "BEGIN PGP SIGNED MESSAGE") { + t.Errorf("control: signing inside did not work, so the refusals above prove nothing:\n%s", r.out) + } + if got := uids(h.fpr); got != before { + t.Errorf("the host key's user IDs changed from %d to %d", before, got) + } + if strings.Contains(h.gpg("--list-keys", h.fpr), "evil@attacker.example") { + t.Error("the host key carries the sandbox's user ID") + } +} + +// TestIdentityGPGRefusesAKeyWithNoSigningSubkey: scope sign on a key that has +// only a certifying primary is refused at startup, naming the command that adds +// the subkey. It would catch a fallback to pinning the primary, which signs and +// certifies, and a run that starts and fails every commit with no cause. +func TestIdentityGPGRefusesAKeyWithNoSigningSubkey(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + h.gpg("--quick-gen-key", "Certify only ", "ed25519", "cert", "never") + h.gpg("--quick-add-key", h.fprOf("cert@example.org"), "cv25519", "encr", "never") + certOnly := h.fprOf("cert@example.org") + + r := runEnv(t, h.profile(certOnly, "sign"), []string{"-p", "gpgid"}, proj, "echo started") + if r.ran { + t.Fatalf("the payload ran under scope sign on a key with no signing subkey:\n%s", r.out) + } + if r.code == 0 { + t.Errorf("snug exited 0 refusing nothing:\n%s", r.out) + } + want := "gpg --quick-add-key " + certOnly + " ed25519 sign" + if !strings.Contains(r.out, want) { + t.Errorf("the refusal does not give the fix %q:\n%s", want, r.out) + } + // Control: the same key under decrypt starts, so the refusal is about the + // scope and not about this key being unusable. + ok := runEnv(t, h.profile(certOnly, "decrypt"), []string{"-p", "gpgid"}, proj, "echo started").mustRun(t) + if ok.code != 0 { + t.Errorf("control: decrypt on the same key did not start (exit %d):\n%s", ok.code, ok.out) + } +} + +// TestIdentityGPGRefusesAnUnknownKey: a fingerprint the host has no secret key +// for is refused before the sandbox starts, naming it. +func TestIdentityGPGRefusesAnUnknownKey(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + const unknown = "0000000000000000000000000000000000000001" + r := runEnv(t, h.profile(unknown, "sign"), []string{"-p", "gpgid"}, proj, "echo started") + if r.ran || r.code == 0 || !strings.Contains(r.out, unknown) { + t.Errorf("an unknown fingerprint was not refused by name (ran=%v exit=%d):\n%s", r.ran, r.code, r.out) + } +} + +// TestIdentityGPGProxyDiesWithSnug: the proxy is a goroutine of the snug +// process, so SIGKILL of snug must take the socket with it. The host side +// dials the proxy socket while the sandbox lives (the control that there is +// something to survive), kills snug with SIGKILL, and then the socket refuses +// connections. The host agent, which is not snug's, stays up. +// +// What it does not measure: the socket FILE being removed. A SIGKILLed snug +// cannot clean up; the next run's sweep does (runtime directory tests). +func TestIdentityGPGProxyDiesWithSnug(t *testing.T) { + budget(t, 30*time.Second) + h := newGPGHost(t) + proj, _ := target(t) + sb := startBgSandbox(t, h.profileEnv(), []string{"-p", "gpgid"}, proj, "sleep 120") + sb.ready(t) + + socks, _ := filepath.Glob(filepath.Join(h.xdgRuntime, "snug", "run-*", "gpg-agent.sock")) + if len(socks) != 1 { + t.Fatalf("expected one proxy socket under %s, found %v\n%s", h.xdgRuntime, socks, sb.log()) + } + conn, err := net.DialTimeout("unix", socks[0], 2*time.Second) + if err != nil { + t.Fatalf("control: the proxy socket does not accept while the sandbox lives: %v", err) + } + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 64) + n, _ := conn.Read(buf) + conn.Close() + if !strings.HasPrefix(string(buf[:n]), "OK") { + t.Fatalf("control: the proxy greeted %q", buf[:n]) + } + + if err := sb.proc.cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = sb.proc.wait() + deadline := time.Now().Add(5 * time.Second) + for { + c, err := net.DialTimeout("unix", socks[0], time.Second) + if err != nil { + break + } + c.Close() + if time.Now().After(deadline) { + t.Fatal("the proxy socket still accepts connections after snug was SIGKILLed") + } + time.Sleep(50 * time.Millisecond) + } + if _, err := os.Stat(h.agentSocket); err != nil { + t.Errorf("the host agent's socket is gone: %v", err) + } +}