Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:24-bookworm

# Install a Linux Secret Service backend so VS Code's SecretStorage /
# keytar / libsecret consumers (incl. GitHub Copilot) get a local
# encrypted credential vault instead of the plaintext fallback store.
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
gnome-keyring \
libsecret-1-0 \
libsecret-tools \
dbus \
python3-jeepney \
&& rm -rf /var/lib/apt/lists/*

# Wire up the tmux configuration for the default container user (node)
COPY tmux.conf /home/node/.tmux.conf
RUN chown node:node /home/node/.tmux.conf
Expand Down
15 changes: 15 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@
},
"overrideFeatureInstallOrder": ["ghcr.io/devcontainers/features/rust"],

// Predictable bus + runtime dir so EVERY VS Code Server child process
// (extensions, terminals, tasks) finds the same gnome-keyring instance.
// Without it, extensions race the postStartCommand and fall back to the
// plaintext SecretStorage store. UID 1000 is the `node` user.
"remoteEnv": {
"XDG_RUNTIME_DIR": "/tmp/runtime-1000",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/tmp/runtime-1000/bus"
},

// postStartCommand fires on every container start (incl. Codespaces resume).
// postAttachCommand fires on every VS Code reconnect, so a crashed daemon
// is restarted without rebuilding the container.
"postStartCommand": ".devcontainer/keyring-init.sh",
"postAttachCommand": ".devcontainer/keyring-init.sh",

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},

Expand Down
65 changes: 65 additions & 0 deletions .devcontainer/keyring-bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Headlessly create a persistent default Secret Service collection.

gnome-keyring's spec-compliant CreateCollection requires gcr-prompter
(GTK/Wayland), which devcontainers don't have. This script calls the
non-spec InternalUnsupportedGuiltRiddenInterface.CreateWithMasterPassword
method, which accepts the master password inline. Read from
KEYRING_PASSWORD env (empty by default).

Idempotent: exits 0 if the 'default' alias already resolves.
"""
from __future__ import annotations
import os, sys
from jeepney import DBusAddress, new_method_call
from jeepney.io.blocking import open_dbus_connection

SP, SB = "/org/freedesktop/secrets", "org.freedesktop.secrets"
SS = "org.freedesktop.Secret.Service"
GI = "org.gnome.keyring.InternalUnsupportedGuiltRiddenInterface"


def main() -> int:
conn = open_dbus_connection(bus="SESSION")
svc = DBusAddress(SP, bus_name=SB, interface=SS)

reply = conn.send_and_get_reply(new_method_call(svc, "ReadAlias", "s", ("default",)))
if reply.body and reply.body[0] != "/":
print(f"[keyring-bootstrap] default alias already set to {reply.body[0]}")
return 0

# 'plain' session: master password travels in cleartext over the
# local unix socket. That's fine because the password is empty (or
# an environment variable already inside the container).
reply = conn.send_and_get_reply(
new_method_call(svc, "OpenSession", "sv", ("plain", ("s", "")))
)
_, session_path = reply.body

password = os.environ.get("KEYRING_PASSWORD", "").encode("utf-8")
master = (session_path, b"", password, "text/plain")
attrs = {"org.freedesktop.Secret.Collection.Label": ("s", "Login")}

guilt = DBusAddress(SP, bus_name=SB, interface=GI)
reply = conn.send_and_get_reply(
new_method_call(guilt, "CreateWithMasterPassword",
"a{sv}(oayays)", (attrs, master))
)
if reply.header.message_type.name != "method_return":
print(f"[keyring-bootstrap] CreateWithMasterPassword failed: {reply.body}", file=sys.stderr)
return 1
coll_path = reply.body[0]
print(f"[keyring-bootstrap] created collection {coll_path}")

reply = conn.send_and_get_reply(
new_method_call(svc, "SetAlias", "so", ("default", coll_path))
)
if reply.header.message_type.name != "method_return":
print(f"[keyring-bootstrap] SetAlias failed: {reply.body}", file=sys.stderr)
return 1
print("[keyring-bootstrap] bound 'default' alias")
return 0


if __name__ == "__main__":
sys.exit(main())
97 changes: 97 additions & 0 deletions .devcontainer/keyring-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Bootstraps a per-container Linux Secret Service so VS Code's
# SecretStorage / keytar / libsecret consumers (incl. GitHub Copilot)
# have a local encrypted credential vault.
#
# Idempotent: safe to run on every container start and VS Code attach.
# Run as the remote user; do NOT run as root.

set -euo pipefail

log() { printf '[keyring-init] %s\n' "$*"; }
warn() { printf '[keyring-init] WARNING: %s\n' "$*" >&2; }

UID_NUM="$(id -u)"
XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/runtime-${UID_NUM}}"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
export XDG_RUNTIME_DIR

DBUS_SOCKET="${XDG_RUNTIME_DIR}/bus"
DBUS_PID_FILE="${XDG_RUNTIME_DIR}/dbus.pid"
ENV_FILE="${XDG_RUNTIME_DIR}/keyring.env"

# --- 1. DBus session bus on a stable socket path --------------------------
dbus_alive() {
[[ -S "$DBUS_SOCKET" ]] || return 1
[[ -f "$DBUS_PID_FILE" ]] || return 1
local pid; pid="$(cat "$DBUS_PID_FILE" 2>/dev/null || true)"
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}

if dbus_alive; then
log "reusing existing dbus-daemon on $DBUS_SOCKET"
else
rm -f "$DBUS_SOCKET" "$DBUS_PID_FILE"
if ! dbus-daemon --session --address="unix:path=${DBUS_SOCKET}" \
--nopidfile --fork --print-pid=3 3>"$DBUS_PID_FILE"; then
warn "failed to start dbus-daemon"; exit 0
fi
for _ in 1 2 3 4 5 6 7 8 9 10; do
[[ -S "$DBUS_SOCKET" ]] && break
sleep 0.1
done
[[ -S "$DBUS_SOCKET" ]] || { warn "dbus socket never appeared"; exit 0; }
log "started dbus-daemon (pid $(cat "$DBUS_PID_FILE")) on $DBUS_SOCKET"
fi
export DBUS_SESSION_BUS_ADDRESS="unix:path=${DBUS_SOCKET}"

# --- 2. gnome-keyring-daemon ----------------------------------------------
keyring_alive() {
pgrep -u "$UID_NUM" -x gnome-keyring-d >/dev/null 2>&1
}

if keyring_alive; then
log "reusing existing gnome-keyring-daemon"
else
KEYRING_PASS="${KEYRING_PASSWORD-}"
gk_env="$(printf '%s' "$KEYRING_PASS" \
| gnome-keyring-daemon --daemonize --unlock --components=secrets 2>/dev/null)" || {
warn "gnome-keyring-daemon failed to start"; exit 0
}
log "started gnome-keyring-daemon"
printf '%s\n' "$gk_env" > "${XDG_RUNTIME_DIR}/keyring.daemon-env"
fi

# --- 3. Bootstrap the persistent default collection (first run only) ------
if command -v python3 >/dev/null 2>&1 \
&& python3 -c 'import jeepney' >/dev/null 2>&1; then
KEYRING_PASSWORD="${KEYRING_PASSWORD-}" \
python3 "$(dirname "$0")/keyring-bootstrap.py" \
|| warn "default-collection bootstrap failed; SecretStorage may be read-only"
else
warn "python3-jeepney not available; cannot bootstrap default collection"
fi

# --- 4. Persist env for non-VS-Code shells --------------------------------
{
echo "export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}"
echo "export DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS}"
[[ -f "${XDG_RUNTIME_DIR}/keyring.daemon-env" ]] \
&& sed -n 's/^\([A-Z_]\+\)=\(.*\)$/export \1=\2/p' \
"${XDG_RUNTIME_DIR}/keyring.daemon-env"
} > "$ENV_FILE"

# --- 5. Verify Secret Service round-trip ----------------------------------
if command -v secret-tool >/dev/null 2>&1; then
probe_value="ok-$$"
if printf '%s' "$probe_value" \
| secret-tool store --label=devcontainer-keyring-probe \
app devcontainer-keyring-probe 2>/dev/null \
&& [[ "$(secret-tool lookup app devcontainer-keyring-probe 2>/dev/null)" == "$probe_value" ]]; then
secret-tool clear app devcontainer-keyring-probe 2>/dev/null || true
log "Secret Service verified on $DBUS_SESSION_BUS_ADDRESS"
else
warn "Secret Service probe failed; VS Code extensions may fall back to the plaintext basic store"
fi
fi
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Python bytecode cache
__pycache__/
*.py[cod]
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,74 @@ My personal devcontainer for VSCode.
- htop
- tmux
- vim
- gnome-keyring / libsecret (Linux Secret Service for VS Code SecretStorage)

## Secret storage

A per-container Linux Secret Service (`gnome-keyring`) is started on
container start/attach so VS Code's `SecretStorage` / `keytar` /
`libsecret` consumers (including GitHub Copilot) store credentials in a
local encrypted vault instead of VS Code's plaintext fallback. See
`.devcontainer/keyring-init.sh` and `.devcontainer/keyring-bootstrap.py`.

### Setting a master password

By default the keyring is created with an empty master password, so the
encrypted file is trivial to decrypt from a filesystem snapshot. To
raise the bar, set `KEYRING_PASSWORD`; the init script feeds it to both
the initial collection bootstrap and every subsequent unlock.

In **Codespaces**, add `KEYRING_PASSWORD` as a
[user secret](https://docs.github.com/en/codespaces/managing-your-codespaces/managing-your-account-specific-secrets-for-github-codespaces)
and it is injected automatically.

For a **local Dev Container** (no Codespaces), forward the value from
your host shell through `remoteEnv` in `.devcontainer/devcontainer.json`:

```jsonc
"remoteEnv": {
"XDG_RUNTIME_DIR": "/tmp/runtime-1000",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/tmp/runtime-1000/bus",
"KEYRING_PASSWORD": "${localEnv:KEYRING_PASSWORD}"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot are there any risks about keeping this in the environment? is this still more secure than plaintext or no keyring password?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — yes, it's still strictly better than plaintext or an empty password, with one caveat. I documented the trade-offs in e365430:

  • At rest (the threat this targets): the vault is AES-encrypted with the password, so a snapshot/backup/stolen disk image is useless without it. Empty password = trivially decryptable; plaintext fallback = no protection.
  • At runtime: the password sits in the remote user's environment, so any process running as that user can read it (/proc/<pid>/environ) and unlock the vault. But that same process could already read the secrets directly via SecretStorage, so it doesn't lower the bar below where it already is.
  • Provenance: it's injected at runtime via ${localEnv:...} / Codespaces secret, never baked into the image or committed. The main thing to avoid is hardcoding it in devcontainer.json or leaving it in shell history.

Net: empty password protects almost nothing at rest; a real KEYRING_PASSWORD meaningfully protects the on-disk vault while leaving runtime exposure unchanged.

}
```

Then export the password in the shell that launches VS Code before
opening the container (or add it to your shell profile):

```bash
export KEYRING_PASSWORD='your-strong-password'
```

To rotate the password, delete `~/.local/share/keyrings/` inside the
container and rebuild — the bootstrap recreates the vault with the new
value.

#### Trade-offs of `KEYRING_PASSWORD` in the environment

Setting `KEYRING_PASSWORD` is still strictly better than an empty
password or the plaintext fallback, but it is not perfect:

- **At rest** (the main threat this guards against): the keyring file is
AES-encrypted with the password, so a filesystem snapshot, backup, or
stolen disk image is useless without it. An empty password makes that
encryption trivial to undo; the plaintext fallback offers no
protection at all.
- **At runtime**: the password lives in the remote user's environment,
so any process running as that user can read it (e.g. via
`/proc/<pid>/environ`) and unlock the vault. Note that such a process
could already read the secrets directly through SecretStorage, so this
does not lower the bar below where it already is.
- **Provenance**: the value is injected at runtime via `remoteEnv` /
`${localEnv:...}` or a Codespaces user secret — it is never baked into
the image or committed to the repo. Avoid hardcoding it in
`devcontainer.json`, and prefer not to leave it in shell history.

In short: an empty password protects against essentially nothing at
rest; a real `KEYRING_PASSWORD` meaningfully protects the on-disk vault
while leaving the runtime exposure unchanged.



## VSCode extensions

Expand Down