diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e91cce9..e12eb60 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3aca879..5480a5c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": {}, diff --git a/.devcontainer/keyring-bootstrap.py b/.devcontainer/keyring-bootstrap.py new file mode 100755 index 0000000..51e3dea --- /dev/null +++ b/.devcontainer/keyring-bootstrap.py @@ -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()) diff --git a/.devcontainer/keyring-init.sh b/.devcontainer/keyring-init.sh new file mode 100755 index 0000000..4e08529 --- /dev/null +++ b/.devcontainer/keyring-init.sh @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66f2c2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Python bytecode cache +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index df6efab..d5894a9 100644 --- a/README.md +++ b/README.md @@ -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}" +} +``` + +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//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