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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
81 changes: 74 additions & 7 deletions docs/src/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <fingerprint> 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 |
Expand Down
1 change: 1 addition & 0 deletions docs/src/reference/environment-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |
Expand Down
2 changes: 2 additions & 0 deletions docs/src/reference/profile-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
18 changes: 18 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)+">"))
Expand Down
1 change: 1 addition & 0 deletions internal/cli/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/dryrunjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/gpgdryrun.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading
Loading