Consider decrypting vaults on the CI runner instead of server-side #116

Description

@ineedjet

Context

#111/#114 (implemented in #115) explicitly decided decryption stays
server-side: the CI runner only resolves/downloads refs and pushes
still-encrypted .sops.env files; the target host runs sops decrypt
itself, so the age private key never leaves it. That's a deliberate
trust-boundary choice, not an oversight — this issue is about revisiting
it, not a bug report.

What's prompting a second look

Bootstrapping sops on a fresh host turns out to be more annoying than
expected: it isn't packaged in Ubuntu or Debian at all (checked through
Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
already does in CI. Every target host needs that bootstrap step repeated,
plus its own age private key provisioned and kept in sync with
keys/<target>.pub.

If decryption moved to the runner instead:

  • Target hosts would need neither sops nor a local age key at all — one
    less package to bootstrap, one less secret to provision and rotate per
    host.
  • The age private key would need to live as a GitHub Secret instead of a
    file on each server.

The tradeoff (why this isn't a clear win)

Moving the private key into GitHub Secrets changes the blast radius of a
GitHub Secrets compromise: today, decrypting a target's vaults requires
both GitHub Secrets access (to get the ciphertext/refs) and physical
access to that specific host's local key file. Centralizing decryption in
CI collapses that to GitHub Secrets access alone being sufficient to
decrypt everything, for every target, from one place.

With a single target (hawkeye) today, the bootstrap-friction case is real
but small; the security downside is permanent and scales with however many
targets/hosts this ever grows to.

Proposal

Not a decided migration — write up both models properly (what changes in
deploy/deploy.py, deploy-shared.yml's secrets, and each target's
prerequisites) and weigh them once there's more than one real target to
judge the bootstrap-cost side against, rather than deciding on a single
data point.

Related

#111 (decided server-side decryption as part of the push-based redesign
this would reopen), #114 (the per-app vault delivery this decryption model
carries), #106 (hawkeye's prerequisites list, which currently includes
sops + age key setup on the host).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      Consider decrypting vaults on the CI runner instead of server-side #116

      Description

      @ineedjet

      Context

      #111/#114 (implemented in #115) explicitly decided decryption stays
      server-side: the CI runner only resolves/downloads refs and pushes
      still-encrypted .sops.env files; the target host runs sops decrypt
      itself, so the age private key never leaves it. That's a deliberate
      trust-boundary choice, not an oversight — this issue is about revisiting
      it, not a bug report.

      What's prompting a second look

      Bootstrapping sops on a fresh host turns out to be more annoying than
      expected: it isn't packaged in Ubuntu or Debian at all (checked through
      Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
      pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
      already does in CI. Every target host needs that bootstrap step repeated,
      plus its own age private key provisioned and kept in sync with
      keys/<target>.pub.

      If decryption moved to the runner instead:

      • Target hosts would need neither sops nor a local age key at all — one
        less package to bootstrap, one less secret to provision and rotate per
        host.
      • The age private key would need to live as a GitHub Secret instead of a
        file on each server.

      The tradeoff (why this isn't a clear win)

      Moving the private key into GitHub Secrets changes the blast radius of a
      GitHub Secrets compromise: today, decrypting a target's vaults requires
      both GitHub Secrets access (to get the ciphertext/refs) and physical
      access to that specific host's local key file. Centralizing decryption in
      CI collapses that to GitHub Secrets access alone being sufficient to
      decrypt everything, for every target, from one place.

      With a single target (hawkeye) today, the bootstrap-friction case is real
      but small; the security downside is permanent and scales with however many
      targets/hosts this ever grows to.

      Proposal

      Not a decided migration — write up both models properly (what changes in
      deploy/deploy.py, deploy-shared.yml's secrets, and each target's
      prerequisites) and weigh them once there's more than one real target to
      judge the bootstrap-cost side against, rather than deciding on a single
      data point.

      Related

      #111 (decided server-side decryption as part of the push-based redesign
      this would reopen), #114 (the per-app vault delivery this decryption model
      carries), #106 (hawkeye's prerequisites list, which currently includes
      sops + age key setup on the host).

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        No labels
        No labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          Consider decrypting vaults on the CI runner instead of server-side #116

          Description

          @ineedjet

          Context

          #111/#114 (implemented in #115) explicitly decided decryption stays
          server-side: the CI runner only resolves/downloads refs and pushes
          still-encrypted .sops.env files; the target host runs sops decrypt
          itself, so the age private key never leaves it. That's a deliberate
          trust-boundary choice, not an oversight — this issue is about revisiting
          it, not a bug report.

          What's prompting a second look

          Bootstrapping sops on a fresh host turns out to be more annoying than
          expected: it isn't packaged in Ubuntu or Debian at all (checked through
          Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
          pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
          already does in CI. Every target host needs that bootstrap step repeated,
          plus its own age private key provisioned and kept in sync with
          keys/<target>.pub.

          If decryption moved to the runner instead:

          • Target hosts would need neither sops nor a local age key at all — one
            less package to bootstrap, one less secret to provision and rotate per
            host.
          • The age private key would need to live as a GitHub Secret instead of a
            file on each server.

          The tradeoff (why this isn't a clear win)

          Moving the private key into GitHub Secrets changes the blast radius of a
          GitHub Secrets compromise: today, decrypting a target's vaults requires
          both GitHub Secrets access (to get the ciphertext/refs) and physical
          access to that specific host's local key file. Centralizing decryption in
          CI collapses that to GitHub Secrets access alone being sufficient to
          decrypt everything, for every target, from one place.

          With a single target (hawkeye) today, the bootstrap-friction case is real
          but small; the security downside is permanent and scales with however many
          targets/hosts this ever grows to.

          Proposal

          Not a decided migration — write up both models properly (what changes in
          deploy/deploy.py, deploy-shared.yml's secrets, and each target's
          prerequisites) and weigh them once there's more than one real target to
          judge the bootstrap-cost side against, rather than deciding on a single
          data point.

          Related

          #111 (decided server-side decryption as part of the push-based redesign
          this would reopen), #114 (the per-app vault delivery this decryption model
          carries), #106 (hawkeye's prerequisites list, which currently includes
          sops + age key setup on the host).

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            No labels
            No labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              Consider decrypting vaults on the CI runner instead of server-side #116

              Description

              @ineedjet

              Context

              #111/#114 (implemented in #115) explicitly decided decryption stays
              server-side: the CI runner only resolves/downloads refs and pushes
              still-encrypted .sops.env files; the target host runs sops decrypt
              itself, so the age private key never leaves it. That's a deliberate
              trust-boundary choice, not an oversight — this issue is about revisiting
              it, not a bug report.

              What's prompting a second look

              Bootstrapping sops on a fresh host turns out to be more annoying than
              expected: it isn't packaged in Ubuntu or Debian at all (checked through
              Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
              pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
              already does in CI. Every target host needs that bootstrap step repeated,
              plus its own age private key provisioned and kept in sync with
              keys/<target>.pub.

              If decryption moved to the runner instead:

              • Target hosts would need neither sops nor a local age key at all — one
                less package to bootstrap, one less secret to provision and rotate per
                host.
              • The age private key would need to live as a GitHub Secret instead of a
                file on each server.

              The tradeoff (why this isn't a clear win)

              Moving the private key into GitHub Secrets changes the blast radius of a
              GitHub Secrets compromise: today, decrypting a target's vaults requires
              both GitHub Secrets access (to get the ciphertext/refs) and physical
              access to that specific host's local key file. Centralizing decryption in
              CI collapses that to GitHub Secrets access alone being sufficient to
              decrypt everything, for every target, from one place.

              With a single target (hawkeye) today, the bootstrap-friction case is real
              but small; the security downside is permanent and scales with however many
              targets/hosts this ever grows to.

              Proposal

              Not a decided migration — write up both models properly (what changes in
              deploy/deploy.py, deploy-shared.yml's secrets, and each target's
              prerequisites) and weigh them once there's more than one real target to
              judge the bootstrap-cost side against, rather than deciding on a single
              data point.

              Related

              #111 (decided server-side decryption as part of the push-based redesign
              this would reopen), #114 (the per-app vault delivery this decryption model
              carries), #106 (hawkeye's prerequisites list, which currently includes
              sops + age key setup on the host).

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                No labels
                No labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  Consider decrypting vaults on the CI runner instead of server-side #116

                  Description

                  @ineedjet

                  Context

                  #111/#114 (implemented in #115) explicitly decided decryption stays
                  server-side: the CI runner only resolves/downloads refs and pushes
                  still-encrypted .sops.env files; the target host runs sops decrypt
                  itself, so the age private key never leaves it. That's a deliberate
                  trust-boundary choice, not an oversight — this issue is about revisiting
                  it, not a bug report.

                  What's prompting a second look

                  Bootstrapping sops on a fresh host turns out to be more annoying than
                  expected: it isn't packaged in Ubuntu or Debian at all (checked through
                  Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
                  pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
                  already does in CI. Every target host needs that bootstrap step repeated,
                  plus its own age private key provisioned and kept in sync with
                  keys/<target>.pub.

                  If decryption moved to the runner instead:

                  • Target hosts would need neither sops nor a local age key at all — one
                    less package to bootstrap, one less secret to provision and rotate per
                    host.
                  • The age private key would need to live as a GitHub Secret instead of a
                    file on each server.

                  The tradeoff (why this isn't a clear win)

                  Moving the private key into GitHub Secrets changes the blast radius of a
                  GitHub Secrets compromise: today, decrypting a target's vaults requires
                  both GitHub Secrets access (to get the ciphertext/refs) and physical
                  access to that specific host's local key file. Centralizing decryption in
                  CI collapses that to GitHub Secrets access alone being sufficient to
                  decrypt everything, for every target, from one place.

                  With a single target (hawkeye) today, the bootstrap-friction case is real
                  but small; the security downside is permanent and scales with however many
                  targets/hosts this ever grows to.

                  Proposal

                  Not a decided migration — write up both models properly (what changes in
                  deploy/deploy.py, deploy-shared.yml's secrets, and each target's
                  prerequisites) and weigh them once there's more than one real target to
                  judge the bootstrap-cost side against, rather than deciding on a single
                  data point.

                  Related

                  #111 (decided server-side decryption as part of the push-based redesign
                  this would reopen), #114 (the per-app vault delivery this decryption model
                  carries), #106 (hawkeye's prerequisites list, which currently includes
                  sops + age key setup on the host).

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    No labels
                    No labels

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      Consider decrypting vaults on the CI runner instead of server-side #116

                      Description

                      @ineedjet

                      Context

                      #111/#114 (implemented in #115) explicitly decided decryption stays
                      server-side: the CI runner only resolves/downloads refs and pushes
                      still-encrypted .sops.env files; the target host runs sops decrypt
                      itself, so the age private key never leaves it. That's a deliberate
                      trust-boundary choice, not an oversight — this issue is about revisiting
                      it, not a bug report.

                      What's prompting a second look

                      Bootstrapping sops on a fresh host turns out to be more annoying than
                      expected: it isn't packaged in Ubuntu or Debian at all (checked through
                      Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
                      pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
                      already does in CI. Every target host needs that bootstrap step repeated,
                      plus its own age private key provisioned and kept in sync with
                      keys/<target>.pub.

                      If decryption moved to the runner instead:

                      • Target hosts would need neither sops nor a local age key at all — one
                        less package to bootstrap, one less secret to provision and rotate per
                        host.
                      • The age private key would need to live as a GitHub Secret instead of a
                        file on each server.

                      The tradeoff (why this isn't a clear win)

                      Moving the private key into GitHub Secrets changes the blast radius of a
                      GitHub Secrets compromise: today, decrypting a target's vaults requires
                      both GitHub Secrets access (to get the ciphertext/refs) and physical
                      access to that specific host's local key file. Centralizing decryption in
                      CI collapses that to GitHub Secrets access alone being sufficient to
                      decrypt everything, for every target, from one place.

                      With a single target (hawkeye) today, the bootstrap-friction case is real
                      but small; the security downside is permanent and scales with however many
                      targets/hosts this ever grows to.

                      Proposal

                      Not a decided migration — write up both models properly (what changes in
                      deploy/deploy.py, deploy-shared.yml's secrets, and each target's
                      prerequisites) and weigh them once there's more than one real target to
                      judge the bootstrap-cost side against, rather than deciding on a single
                      data point.

                      Related

                      #111 (decided server-side decryption as part of the push-based redesign
                      this would reopen), #114 (the per-app vault delivery this decryption model
                      carries), #106 (hawkeye's prerequisites list, which currently includes
                      sops + age key setup on the host).

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        No labels
                        No labels

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          Consider decrypting vaults on the CI runner instead of server-side #116

                          Description

                          @ineedjet

                          Context

                          #111/#114 (implemented in #115) explicitly decided decryption stays
                          server-side: the CI runner only resolves/downloads refs and pushes
                          still-encrypted .sops.env files; the target host runs sops decrypt
                          itself, so the age private key never leaves it. That's a deliberate
                          trust-boundary choice, not an oversight — this issue is about revisiting
                          it, not a bug report.

                          What's prompting a second look

                          Bootstrapping sops on a fresh host turns out to be more annoying than
                          expected: it isn't packaged in Ubuntu or Debian at all (checked through
                          Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
                          pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
                          already does in CI. Every target host needs that bootstrap step repeated,
                          plus its own age private key provisioned and kept in sync with
                          keys/<target>.pub.

                          If decryption moved to the runner instead:

                          • Target hosts would need neither sops nor a local age key at all — one
                            less package to bootstrap, one less secret to provision and rotate per
                            host.
                          • The age private key would need to live as a GitHub Secret instead of a
                            file on each server.

                          The tradeoff (why this isn't a clear win)

                          Moving the private key into GitHub Secrets changes the blast radius of a
                          GitHub Secrets compromise: today, decrypting a target's vaults requires
                          both GitHub Secrets access (to get the ciphertext/refs) and physical
                          access to that specific host's local key file. Centralizing decryption in
                          CI collapses that to GitHub Secrets access alone being sufficient to
                          decrypt everything, for every target, from one place.

                          With a single target (hawkeye) today, the bootstrap-friction case is real
                          but small; the security downside is permanent and scales with however many
                          targets/hosts this ever grows to.

                          Proposal

                          Not a decided migration — write up both models properly (what changes in
                          deploy/deploy.py, deploy-shared.yml's secrets, and each target's
                          prerequisites) and weigh them once there's more than one real target to
                          judge the bootstrap-cost side against, rather than deciding on a single
                          data point.

                          Related

                          #111 (decided server-side decryption as part of the push-based redesign
                          this would reopen), #114 (the per-app vault delivery this decryption model
                          carries), #106 (hawkeye's prerequisites list, which currently includes
                          sops + age key setup on the host).

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            No labels
                            No labels

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              Consider decrypting vaults on the CI runner instead of server-side #116

                              Description

                              @ineedjet

                              Context

                              #111/#114 (implemented in #115) explicitly decided decryption stays
                              server-side: the CI runner only resolves/downloads refs and pushes
                              still-encrypted .sops.env files; the target host runs sops decrypt
                              itself, so the age private key never leaves it. That's a deliberate
                              trust-boundary choice, not an oversight — this issue is about revisiting
                              it, not a bug report.

                              What's prompting a second look

                              Bootstrapping sops on a fresh host turns out to be more annoying than
                              expected: it isn't packaged in Ubuntu or Debian at all (checked through
                              Ubuntu 26.04 and Debian sid/trixie/forky) — the only install path is a
                              pinned binary/.deb from GitHub Releases, same as encrypt-env/action.yml
                              already does in CI. Every target host needs that bootstrap step repeated,
                              plus its own age private key provisioned and kept in sync with
                              keys/<target>.pub.

                              If decryption moved to the runner instead:

                              • Target hosts would need neither sops nor a local age key at all — one
                                less package to bootstrap, one less secret to provision and rotate per
                                host.
                              • The age private key would need to live as a GitHub Secret instead of a
                                file on each server.

                              The tradeoff (why this isn't a clear win)

                              Moving the private key into GitHub Secrets changes the blast radius of a
                              GitHub Secrets compromise: today, decrypting a target's vaults requires
                              both GitHub Secrets access (to get the ciphertext/refs) and physical
                              access to that specific host's local key file. Centralizing decryption in
                              CI collapses that to GitHub Secrets access alone being sufficient to
                              decrypt everything, for every target, from one place.

                              With a single target (hawkeye) today, the bootstrap-friction case is real
                              but small; the security downside is permanent and scales with however many
                              targets/hosts this ever grows to.

                              Proposal

                              Not a decided migration — write up both models properly (what changes in
                              deploy/deploy.py, deploy-shared.yml's secrets, and each target's
                              prerequisites) and weigh them once there's more than one real target to
                              judge the bootstrap-cost side against, rather than deciding on a single
                              data point.

                              Related

                              #111 (decided server-side decryption as part of the push-based redesign
                              this would reopen), #114 (the per-app vault delivery this decryption model
                              carries), #106 (hawkeye's prerequisites list, which currently includes
                              sops + age key setup on the host).

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                No labels
                                No labels

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions