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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/src/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,54 @@ 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.

#### Your gpg.conf

The gpg client inside makes the digest, cipher and trust choices your host's
gpg makes. snug reads your `gpg.conf` (the one gpg reads: `gpg.conf-<version>`
first, as gpg does) and the system-wide one in gpg's sysconfdir
(`/etc/gnupg/gpg.conf`), and writes the sandbox its own copy of each holding
only these options:

- digest and cipher choices: `personal-digest-preferences`,
`personal-cipher-preferences`, `personal-aead-preferences`, `digest-algo`,
`cert-digest-algo`, `cipher-algo`, `aead-algo`, `s2k-mode`, `s2k-digest-algo`,
`s2k-cipher-algo`, `s2k-count`, `force-aead`, `no-symkey-cache`
- what verification rejects: `weak-digest`, `disable-cipher-algo`,
`disable-pubkey-algo`, `assert-pubkey-algo`, `require-cross-certification`,
`require-backsigs`, `require-secmem`, `require-pqc-encryption`
- compliance and trust: `compliance`, `require-compliance`, `trust-model`,
`completes-needed`, `marginals-needed`, `max-cert-depth`, `min-cert-level`,
`trusted-key`, `tofu-default-policy`, `assert-signer`
- what an encrypted message gives away: `throw-keyids`, `no-throw-keyids`,
`disable-signer-uid`, `personal-compress-preferences`, `compress-algo`,
`compress-level`, `bzip2-compress-level`, `no-compress`
- gpg's other spellings of the above: `personal-cipher-prefs`,
`personal-compress-prefs`, `compression-algo`, `force-ocb`, `use-ocb-sym`,
and the compliance shortcuts `gnupg`, `openpgp`, `rfc2440`, `rfc4880`,
`rfc4880bis`, `pgp6`, `pgp7`, `pgp8`, `no-pgp2`, `no-pgp6`, `no-pgp7`,
`no-pgp8`

Each line keeps your spelling and your file's order. gpg's `[force]` locks one
spelling, not the option behind it, so rewriting `openpgp` to
`compliance openpgp` would change what a forced line overrides. `trusted-key`
and `assert-signer` are carried only as key IDs or fingerprints: gpg also
accepts a file name there, and a file inside the sandbox is not your file.

The sandbox's own `gpg.conf` also ends with `disable-dirmngr`, which snug adds
itself. The sandbox's keyring is read-only, so a key looked up over the network
could never be imported, and the lookup would only tell the network which
address you asked about.

The system file's `[force]`, `[ignore]` and `[ignore-all]` lines are kept in
place, so gpg inside applies them as yours does. Everything else stays behind:
options that run a program (`exec-path`, `photo-viewer`), that reach the
network (`keyserver`, `auto-key-retrieve`, `auto-key-locate`), and the
relaxations (`allow-weak-digest-algos`, `always-trust`, and the like). Without a
relaxation, gpg inside is stricter than yours, never looser.

If snug cannot read either file, or a kept option has a value it cannot write
back exactly, it refuses to start and names the file and line.

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
Expand Down
273 changes: 273 additions & 0 deletions internal/cli/gpgconf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
package cli

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"

"github.com/gomoni/snug/internal/hostread"
"github.com/gomoni/snug/internal/policy"
)

// gpgConfFiles is the host's gpg configuration as gpg itself would read it:
// the system-wide file and the ONE user file gpg picks, each as raw bytes and
// the path they came from. An absent file is a nil content and an empty path.
// The system file's host path is also its guest path: the sandbox runs the
// same gpg, with the same compiled-in sysconfdir.
type gpgConfFiles struct {
system []byte
systemPath string
user []byte
userPath string
}

// probeGPGConf finds the configuration the host's gpg CLIENT runs on, so the
// sandbox's gpg makes the same digest, cipher and trust choices.
//
// WHY NOT `gpgconf --list-options gpg`: it does not list the options that
// matter. Measured on gpg 2.5.24 with a GNUPGHOME whose gpg.conf set
// personal-digest-preferences, digest-algo, cert-digest-algo, weak-digest,
// personal-cipher-preferences, compliance, trust-model, require-compliance and
// keyserver, the listing carried values for compliance, trust-model and
// keyserver only. So snug reads the files, and asks gpgconf only where they
// are: its homedir honours GNUPGHOME, its sysconfdir is the compiled-in
// /etc/gnupg.
//
// Which user file: gpg reads the first that exists of gpg.conf-<version>,
// gpg.conf-<major>.<minor>, gpg.conf-<major> and gpg.conf, and only that one
// — measured on 2.5.24, where gpg.conf-2.5.24 beat gpg.conf-2.5, gpg.conf-2.5
// beat gpg.conf, gpg.conf-2 beat gpg.conf, and a gpg.conf-2.5.2 or
// gpg.conf-2.4 was ignored. The system directory has no versioned variant: a
// /etc/gnupg/gpg.conf-2.5 was ignored beside /etc/gnupg/gpg.conf.
//
// Every failure is an error: a probe that could not run, a version it cannot
// read, a file that exists and cannot be read. Each would otherwise leave the
// sandbox's gpg on compiled-in defaults the host may have tightened. One of
// them is stricter than gpg: gpg skips a file it may not read (measured, mode
// 000: a gpg.conf gives compiled-in defaults, a gpg.conf-2.5 falls back to
// gpg.conf), and snug refuses instead of copying that.
func probeGPGConf() (gpgConfFiles, error) {
var f gpgConfFiles
gpgconf, err := exec.LookPath("gpgconf")
if err != nil {
return f, errors.New("identity.gpg needs gpgconf on the host to find gpg's " +
"configuration, and no gpgconf is on PATH")
}
ctx, cancel := context.WithTimeout(context.Background(), gpgHostTimeout)
defer cancel()
// Bare `--list-dirs`, read by name: with names as arguments gpgconf
// prints the values in ITS order, not theirs (measured: `--list-dirs
// homedir sysconfdir` printed /etc/gnupg first).
out, err := exec.CommandContext(ctx, gpgconf, "--list-dirs").Output()
if err != nil {
return f, fmt.Errorf("identity.gpg: `gpgconf --list-dirs` failed: %v", err)
}
dirs := map[string]string{}
for line := range strings.SplitSeq(string(out), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok {
dirs[k] = v
}
}
// A `%` is gpgconf's percent-escaping of a `:` or `%` in the path;
// refused rather than decoded, as snug has never measured it.
homedir, sysconfdir := dirs["homedir"], dirs["sysconfdir"]
if !cleanAbs(homedir) || !cleanAbs(sysconfdir) ||
strings.Contains(homedir+sysconfdir, "%") {
return f, fmt.Errorf("identity.gpg: `gpgconf --list-dirs` gave homedir %q and sysconfdir %q, "+
"and snug needs both as plain absolute paths", policy.VisibleText(homedir),
policy.VisibleText(sysconfdir))
}

gpg, err := exec.LookPath("gpg")
if err != nil {
return f, errors.New("identity.gpg needs gpg on the host, and none is on PATH")
}
vout, err := exec.CommandContext(ctx, gpg, "--batch", "--version").Output()
if err != nil {
return f, fmt.Errorf("identity.gpg: `gpg --version` failed: %v", err)
}
version, err := parseGPGVersion(string(vout))
if err != nil {
return f, err
}

f.systemPath = filepath.Join(sysconfdir, policy.GPGSystemConfName)
if f.system, err = readGPGConf(f.systemPath); err != nil {
return f, err
}
if f.system == nil {
f.systemPath = ""
}
for _, name := range gpgUserConfNames(version) {
p := filepath.Join(homedir, name)
b, err := readGPGConf(p)
if err != nil {
return f, err
}
if b != nil {
f.user, f.userPath = b, p
break
}
}
return f, nil
}

func cleanAbs(p string) bool { return filepath.IsAbs(p) && filepath.Clean(p) == p }

// maxGPGConfBytes bounds each gpg.conf read. A hand-written gpg.conf is a few
// dozen lines; a megabyte is not one.
const maxGPGConfBytes = 1 << 20

// readGPGConf is a file's bytes, or nil when gpg would find nothing there.
// ENOENT is the only absence: a dangling symlink gives it too, and gpg reads
// one as absent (measured). A FIFO, a device, a directory or an oversized file
// is refused by hostread rather than read.
func readGPGConf(p string) ([]byte, error) {
b, err := hostread.Required(p, maxGPGConfBytes)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("identity.gpg: cannot read %s (%v), so snug cannot carry the "+
"host's gpg settings into the sandbox and will not start it on gpg's compiled-in "+
"defaults instead.\n Make it a readable file, or remove it if gpg is not meant "+
"to read it", policy.VisibleText(p), err)
}
if b == nil {
b = []byte{}
}
return b, nil
}

var gpgVersionLine = regexp.MustCompile(`^gpg \(GnuPG[^)]*\) ([0-9]+(?:\.[0-9]+)*)$`)

// parseGPGVersion is the dotted version on `gpg --version`'s first line,
// "gpg (GnuPG) 2.5.24" on the development host.
func parseGPGVersion(out string) (string, error) {
first, _, _ := strings.Cut(out, "\n")
m := gpgVersionLine.FindStringSubmatch(strings.TrimSpace(first))
if m == nil {
return "", fmt.Errorf("identity.gpg: `gpg --version` begins %q, and snug needs the "+
"dotted version to know which gpg.conf-<version> gpg reads", policy.VisibleText(first))
}
return m[1], nil
}

// gpgUserConfNames is the order gpg looks for its user file: the full version,
// then shorter by one component at a time, then the bare name.
func gpgUserConfNames(version string) []string {
var out []string
for v := version; v != ""; {
out = append(out, policy.GPGSystemConfName+"-"+v)
i := strings.LastIndexByte(v, '.')
if i < 0 {
break
}
v = v[:i]
}
return append(out, policy.GPGSystemConfName)
}

// probeAndStageGPGConf is probeGPGConf followed by stageGPGConf, and under -v
// names what each host file carried and what it left behind, by option name
// only (every drop is named: .claude/design/GENERATED-CONFIG.md §6a).
func probeAndStageGPGConf(pol *policy.Policy, verbose bool) error {
f, err := probeGPGConf()
if err != nil {
return err
}
if err := stageGPGConf(pol, f); err != nil {
return err
}
if verbose {
for _, src := range []struct {
path string
b []byte
system bool
}{{f.systemPath, f.system, true}, {f.userPath, f.user, false}} {
if src.b == nil {
continue
}
carried, dropped := policy.GPGConfSummary(src.b, src.system)
fmt.Fprintf(os.Stderr, "snug: gpg.conf %s: carried %s; left on the host %s\n",
policy.VisibleText(src.path), gpgConfNames(carried), gpgConfNames(dropped))
}
}
return nil
}

func gpgConfNames(names []string) string {
if len(names) == 0 {
return "(none)"
}
return strings.Join(names, " ")
}

// stageGPGConf mounts the generated gpg.conf files: the user one always, so
// --dry-run shows what the sandbox's gpg reads even on a host that sets
// nothing, and the system one only where the host has one, so the sandbox's
// gpg sees the same files present as the host's.
//
// A profile's own grant at ~/.gnupg/gpg.conf (a stub a human wrote) is
// REFUSED when the host's user file carries anything, never displaced: two
// authors for one file, and either silent winner drops the other's options.
// When the host's user file carries nothing, the stub stays and no user file
// is generated — the stub is then the only author.
func stageGPGConf(pol *policy.Policy, f gpgConfFiles) error {
userHost := f.userPath
if userHost == "" {
userHost = "(none: no gpg.conf in the host's GNUPGHOME)"
}
user, err := policy.GPGConfFrom(f.user, userHost, false)
if err != nil {
return fmt.Errorf("identity.gpg: %v", err)
}
var system []byte
if f.system != nil {
if system, err = policy.GPGConfFrom(f.system, f.systemPath, true); err != nil {
return fmt.Errorf("identity.gpg: %v", err)
}
}
userGuest := pol.Home + "/" + policy.GPGConfGuest
// gpg reads the first of gpg.conf-<version>…gpg.conf (probeGPGConf), so a
// grant at a versioned name would be read INSTEAD of the generated file,
// carried options and all, whatever the host sets.
for guest, m := range pol.Mounts {
if strings.HasPrefix(guest, userGuest+"-") && !m.Anchor {
return fmt.Errorf("%s grants %s, which the sandbox's gpg reads in place of the %s "+
"identity.gpg (profile %q) generates from the host's.\n"+
" Drop the grant", strings.Join(m.From, "+"), policy.VisibleText(guest),
userGuest, pol.IdentityOwner("gpg"))
}
}
stub, haveStub := pol.Mounts[userGuest]
haveStub = haveStub && !stub.Anchor
if haveStub {
if carried, _ := policy.GPGConfSummary(f.user, false); len(carried) > 0 {
return fmt.Errorf("identity.gpg (profile %q) generates %s from the host's %s, and "+
"%s also grants that path.\n"+
" Two authors cannot both hold one file. Drop the grant, or move what it "+
"sets into the host's gpg.conf", pol.IdentityOwner("gpg"), userGuest,
policy.VisibleText(f.userPath), strings.Join(stub.From, "+"))
}
}
if system != nil {
pol.Replace(policy.Mount{
Guest: f.systemPath, Kind: policy.KindData, Access: policy.AccessRO,
Content: system, From: []string{identityProvenance(pol, "gpg")},
})
}
if !haveStub {
pol.Replace(policy.Mount{
Guest: userGuest, Kind: policy.KindData, Access: policy.AccessRO, Content: user,
From: []string{identityProvenance(pol, "gpg")},
})
}
return nil
}
Loading
Loading