From 5008576f826edc4c89c9c0e3d5ce907b9a73ab66 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Tue, 4 Aug 2026 12:45:31 +0100 Subject: [PATCH] Store storefront config in encrypted ejson files Root `.env` currently serves both the sample apps and the E2E suite, so running the suite overwrites the storefront config a developer keeps for their own store, and none of those values can be shared or reviewed. This first step adds the encrypted source of truth and the tooling around it. Nothing reads these files yet, so behaviour is unchanged. - `config/secrets/demo.ejson` and `config/secrets/e2e.ejson` hold every value, secret and non-secret, so no plaintext config exists to commit by accident. Each holds one `environment` object, the only key `ejson2env` reads, plus a `_description` that ejson leaves unencrypted. - `scripts/ejson_lint` fails on any unencrypted value and reports key names only. It needs no private key, so it runs on forks. Wired into `dev check`. - `scripts/install_ejson_key` fetches the private key from GCP Secret Manager into the ejson keydir during `dev up`. The key travels through a redirect, so it never reaches argv, an environment variable, or the output. Every failure path exits 0: a developer without the key keeps full control of their own `.env`. - `scripts/secrets_edit` backs `dev secrets edit `. It decrypts to a private temporary copy, opens $EDITOR, restores the ciphertext of unchanged values so the diff shows only real changes, then writes back content that is already encrypted. No plaintext reaches the working tree. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 11 +- config/secrets/demo.ejson | 23 ++ config/secrets/e2e.ejson | 17 + dev.yml | 17 + scripts/ejson_lint | 44 ++ scripts/lib/ejson_secrets.rb | 93 +++++ scripts/secrets_edit | 187 +++++++++ scripts/secrets_setup | 248 +++++++++++ scripts/test/ejson_plaintext_guard_test.rb | 76 ++++ scripts/test/ejson_secrets_merge_test.rb | 135 ++++++ scripts/test/secrets_edit_test.rb | 267 ++++++++++++ scripts/test/secrets_setup_test.rb | 453 +++++++++++++++++++++ 12 files changed, 1570 insertions(+), 1 deletion(-) create mode 100644 config/secrets/demo.ejson create mode 100644 config/secrets/e2e.ejson create mode 100755 scripts/ejson_lint create mode 100644 scripts/lib/ejson_secrets.rb create mode 100755 scripts/secrets_edit create mode 100755 scripts/secrets_setup create mode 100644 scripts/test/ejson_plaintext_guard_test.rb create mode 100644 scripts/test/ejson_secrets_merge_test.rb create mode 100644 scripts/test/secrets_edit_test.rb create mode 100644 scripts/test/secrets_setup_test.rb diff --git a/.gitignore b/.gitignore index 2f382e802..72c4616eb 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,14 @@ captures/ # Android local config / secrets local.properties -.env upload-keystore.jks + +# Storefront configuration. .env and e2e/.env are generated from config/secrets/*.ejson. +# The .local files are hand-written overrides that no tooling reads or writes. +.env +.env.local +e2e/.env +e2e/.env.local + +# Decrypted ejson output must never be committed. +config/secrets/*.json diff --git a/config/secrets/demo.ejson b/config/secrets/demo.ejson new file mode 100644 index 000000000..d7cb44edd --- /dev/null +++ b/config/secrets/demo.ejson @@ -0,0 +1,23 @@ +{ + "_public_key": "58b34b9a2be67c206423293ba2c0317e6fbe8f727f7ac124ff62349d36fa0136", + "_description": "Storefront config for the sample apps. Generates .env, run `dev up` to propagate these values to the sample apps.", + "environment": { + "STOREFRONT_DOMAIN": "EJ[1:Vsi9n4WYYvGd935C37cvRzygtUXcpBHu1CkCTFFUqzQ=:MDf5DnXyI+E2j9n1zeMMNprubZ0QpUSS:Ratyqcyc9xd5Ha14St4V0YFjKbt8DhbLK5cOC5GSbGdFyBnHuvtm5mdomVFHqCetPuulRsk=]", + "STOREFRONT_ACCESS_TOKEN": "EJ[1:Vsi9n4WYYvGd935C37cvRzygtUXcpBHu1CkCTFFUqzQ=:31/QaDjKTxu+CgWP1+Pps3/TSphwS16+:oWNA0Zg7iehiUz/kzXPI2sgBV4us6UNEtj2akQTeb3+vF0XRVYSjJfUIa6wqL2Sf]", + "API_VERSION": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:tYFS86UJtujPHsIZHB/u4wKb2isFXQFC:/a2Zafq6GTzkympf135XmS+ZHh7L3Zg=]", + "CUSTOMER_ACCOUNT_API_CLIENT_ID": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:YbjANfMN0CrNMGQ8mHuX7LOSZunMDGl+:KjleSaYcWwtnMkZ3U8WSGNsXq23sOpyD4YYv16WhXhLdyoy3bfe8ShQXE/SOLPdswlo3WA==]", + "CUSTOMER_ACCOUNT_API_SHOP_ID": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:KjkLuMIgiMHdO1kBj24PTWjmumLq0trf:PFTqWETJCU6Rubg8Zc0+eQT1S5fEBVNDfYOG]", + "CUSTOMER_ACCOUNT_API_VERSION": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:RsFsAs0iSr9CP7yOse551+RDXpFvFVYA:yayckGEGdFI4GKceEnsPwRL0IY9JhfA=]", + "EMAIL": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:ZZEAfmGrEjXtoaIz7EJwnS+kM9ZdXzAN:ppYNP2mjoASzJLis5/+FGlHsLCRXbs11wTMsnDfUs8CbCySkqJikfg==]", + "ADDRESS_1": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:uEBXKgT0+DysFGMvoWdso+2ZB8LGVYE4:z5XDRgOKe6A4QiV+7hvIbH3i0egj1BHQX/A9qEJiBg==]", + "ADDRESS_2": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:WuAEJvIVocmodF2FMiem57+aIJynQGJY:uL26TiNpWKU66f6erd7dgGwVstbdVuo1Z7g=]", + "CITY": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:4OTv6HnlxH4PlBmQ+ANJTbmWtX8jAA3t:EP/DhMI448V5rj3HHvbwcD0YVF0p6Mw=]", + "COMPANY": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:V52RnO2DvW+NifdVAkpQBw2xr82njk7z:+27hhwo3m7iEvXCjIR00ajMRL39ibT4=]", + "COUNTRY": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:7xfE2oUFcNCh3L3I1qLEoon/3mjKp9Ft:yudvhxOLb/qm2qnIMAmUoxSf]", + "FIRST_NAME": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:C3w6+5mky0lNnle74O2SbGqDvmdnCnO6:RTrFHJRGzhsC6fAyCmfcEJ9LcaIGow==]", + "LAST_NAME": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:Y6D/bjRLXox1Ev2x1wzwYkCcnSzyRqQr:Jy1SP2wB1gcl3EejYjG+BN7qguQeyEM=]", + "PROVINCE": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:0TOrvPZDSNScmprg0GXGTCll0xiNg+sl:Y5zUNb4NN+0nUWUC/J1D0jjR]", + "ZIP": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:i2R2JMndnE4CNZyGFxRrHQb9XJoHvGSP:wdAactHwHtDOTAhjdJ+wR3T2LRQ6IkI=]", + "PHONE": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:03PvHIo8P/rTDTomdb8wS+PH651lcbWY:j1idH9SHYOeh9iocqGY/wE689kiBKoRpATLD]" + } +} diff --git a/config/secrets/e2e.ejson b/config/secrets/e2e.ejson new file mode 100644 index 000000000..abea3c8f8 --- /dev/null +++ b/config/secrets/e2e.ejson @@ -0,0 +1,17 @@ +{ + "_public_key": "58b34b9a2be67c206423293ba2c0317e6fbe8f727f7ac124ff62349d36fa0136", + "_description": "Storefront config for the end-to-end suite. Generates e2e/.env.", + "environment": { + "STOREFRONT_DOMAIN": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:8lv6Go1zrCQRnEtabS5nk+9JCUMnALP0:eusVPXtuv/pma99Sf19ozKCRvQsseSWo8JbgSyYsYaiLl/iUCF+IXrzSM2jcpcs=]", + "STOREFRONT_ACCESS_TOKEN": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:9x44AskSQ93mdmNlTGd2zRuKusbUnbeS:yzYvkMCmReAmkx6IpuB/FQa0lyEEYfdNA84DhzPJmnquyiVYUj9boycY423cnKWq]", + "API_VERSION": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:ZpCHDGUxRQeZTxzF2vUowAi0K7JyCZgs:KTbKD6MXHb2Vnxq+X0O798rPNygsqIA=]", + "CUSTOMER_ACCOUNT_API_CLIENT_ID": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:geNUyh2750qSEUZXeoL6t83YEIYey2VD:41Ko6xROWrYWpNEaloAysO2Wg4s2cAvvu2Ir7U2qNiRO5dBs/iv/pCmcqIxMWahz9XahhLw1WK8=]", + "CUSTOMER_ACCOUNT_API_SHOP_ID": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:+zzl6LLa9DcQk5uuFk9MVtqe8sjynIZq:pCBPXD/Bq0PbCblUSbzIZluGbUUkPhaJBoUB]", + "CUSTOMER_ACCOUNT_API_VERSION": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:e4t8SDzEDw9Pd/LPQ0jqWQzcQKyxcNCm:lFC31+y3NzmdwGgufq3ROB9drg74JtA=]", + "CUSTOM_USER_AGENT": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:GME8U4ZAevmXbPGeniJJQqWjTXiznKYX:vTUK37oq9TGqm58bsfTaZ4U+pAhCv9ten0vk1aVjaIw5uM6kI2811ARhiOeB]", + "E2E_CUSTOMER_ACCOUNT_EMAIL": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:t9UWZ0cGzWxPQwui0xBZpgzUvz4/QEub:dImY1xP80JyXqbtGyWgh2oGwrhPb7+Vw7RMDftBQw9k6GSo8fVth7Cy/UW+uVdVP]", + "E2E_CUSTOMER_ACCOUNT_CODE": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:0YCrp9nTRLyzqnLD/gB2vgv++zIuilbD:Q8pKairGBA0lW3bnkFz5VAGzAly7Fw==]", + "BROWSERSTACK_USERNAME": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:sqzucn2rE3m+fWQ8TfkeOvbvpm1zepVc:irn3lHv1TTtR+yBVfr444TRL38v4uLzWasX8dwAkJ/mpdM8=]", + "BROWSERSTACK_ACCESS_KEY": "EJ[1:48KZ0RasIKqg8gRVb7Rwy6K9qi8im2NNvYaa0Wqs8Fk=:ZI9+RU3vUBM9tXGg9e39zqlNaduNFxIC:EJ0WIJAzyB/UnnMYrMN8ILZpvOP9KE2fwcMoRQQqROq63u93]" + } +} diff --git a/dev.yml b/dev.yml index 77400c533..41edecd57 100644 --- a/dev.yml +++ b/dev.yml @@ -11,6 +11,8 @@ up: - swiftformat - sccache - bitrise + - ejson + - ejson2env - ruby - xcode: version: "26.2" @@ -25,6 +27,10 @@ up: - protocol - platforms/react-native - platforms/web + - custom: + name: Check storefront secrets + met?: ./scripts/secrets_setup --check + meet: "true" - custom: name: Copy root env into worktree met?: ./scripts/copy_worktree_env --check @@ -52,6 +58,7 @@ open: "PRs": "https://github.com/Shopify/checkout-kit/pulls" check: + ejson-plaintext: ./scripts/ejson_lint storefront-env-tests: ./scripts/test_setup_storefront_env ruby-script-tests: ./scripts/test_ruby android-detekt: platforms/android/gradlew -p platforms/android detekt @@ -93,6 +100,16 @@ commands: desc: Copy the root .env into the current worktree so `dev up` can regenerate sample config run: ./scripts/copy_worktree_env + secrets: + desc: "Edit or set up encrypted storefront config. Usage: dev secrets |setup>" + aliases: [secret] + syntax: "|setup>" + run: | + case "${1:-}" in + setup) shift; ./scripts/secrets_setup "$@" ;; + *) ./scripts/secrets_edit "$@" ;; + esac + tophat: desc: "Install a PR's build to a device via Tophat. Usage: dev tophat [] []" syntax: "[] []" diff --git a/scripts/ejson_lint b/scripts/ejson_lint new file mode 100755 index 000000000..fc2dbcefe --- /dev/null +++ b/scripts/ejson_lint @@ -0,0 +1,44 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Fails when any value in the committed ejson files is not encrypted. +# +# These files live in a public repository, so an unencrypted value is a leak the +# moment it is pushed. The check needs no private key and no ejson binary, so it +# also runs on forks and in CI. +# +# Prints key paths only, never values. + +require_relative "lib/cli_output" +require_relative "lib/ejson_secrets" + +repo_root = File.expand_path("..", __dir__) +paths = Dir.glob("config/secrets/*.ejson", base: repo_root).sort + +if paths.empty? + puts "No ejson files under config/secrets; nothing to lint." + exit 0 +end + +violations = paths.to_h do |path| + [path, EjsonSecrets.plaintext_violations(File.join(repo_root, path))] +rescue EjsonSecrets::InvalidFile => error + CliOutput.die(error.message, hint: "restore the file with `git checkout -- #{path}`") +end + +failed = violations.reject { |_path, keys| keys.empty? } + +if failed.empty? + puts "All values encrypted in: #{paths.join(", ")}" + exit 0 +end + +failed.each do |path, keys| + warn "#{path}: #{keys.length} unencrypted #{keys.length == 1 ? "value" : "values"}" + keys.each { |key| warn " #{key}" } +end + +CliOutput.die( + "Unencrypted values found. They must never be committed.", + hint: "run `ejson encrypt #{failed.keys.join(" ")}` then commit again", +) diff --git a/scripts/lib/ejson_secrets.rb b/scripts/lib/ejson_secrets.rb new file mode 100644 index 000000000..95ebe1965 --- /dev/null +++ b/scripts/lib/ejson_secrets.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "json" + +# Guards the committed ejson files against plaintext values. +# +# The files in config/secrets live in a public repository, so every value has to +# arrive encrypted. ejson leaves two things alone: keys that start with an +# underscore, and any value that is not a string. Both are therefore reported, +# except for the underscore keys that ejson reserves for metadata. +# +# Violations name the key path only. A plaintext value is by definition a +# possible secret, so it never reaches the output. +module EjsonSecrets + ENCRYPTED_PREFIX = "EJ[1:" + + InvalidFile = Class.new(StandardError) + + module_function + + def plaintext_violations(path) + walk(load(path), []) + end + + def load(path) + JSON.parse(File.read(path)) + rescue JSON::ParserError => error + raise InvalidFile, "#{path} is not valid JSON: #{error.message}" + end + + def walk(node, trail) + return [] unless node.is_a?(Hash) + + node.flat_map do |key, value| + next [] if key.start_with?("_") + + path = trail + [key] + + if value.is_a?(Hash) + walk(value, path) + elsif encrypted?(value) + [] + else + [path.join(".")] + end + end + end + + def encrypted?(value) + value.is_a?(String) && value.start_with?(ENCRYPTED_PREFIX) + end + + # Rebuilds a committed file from an edited plaintext copy, keeping the original + # ciphertext wherever the plaintext is unchanged. + # + # `ejson encrypt` skips values that are already encrypted, so anything left as + # plaintext here is what it re-encrypts. Restoring the untouched ciphertext + # therefore keeps the diff to the values that actually changed. Without this, a + # single edit rewrites every value in the file. + def merge_edits(original:, decrypted:, edited:) + edited.to_h do |key, value| + if value.is_a?(Hash) + [key, merge_edits(original: subtree(original, key), decrypted: subtree(decrypted, key), edited: value)] + elsif original.key?(key) && decrypted[key] == value + [key, original[key]] + else + [key, value] + end + end + end + + # Names the keys whose plaintext differs, including keys added or removed. + def changed_keys(decrypted:, edited:, trail: []) + (decrypted.keys | edited.keys).flat_map do |key| + path = trail + [key] + before = decrypted[key] + after = edited[key] + + if before.is_a?(Hash) && after.is_a?(Hash) + changed_keys(decrypted: before, edited: after, trail: path) + elsif before == after + [] + else + [path.join(".")] + end + end + end + + def subtree(node, key) + value = node[key] + value.is_a?(Hash) ? value : {} + end +end diff --git a/scripts/secrets_edit b/scripts/secrets_edit new file mode 100755 index 000000000..04df86649 --- /dev/null +++ b/scripts/secrets_edit @@ -0,0 +1,187 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Edits an encrypted file under config/secrets. +# +# ejson has no edit command, so the cycle is decrypt, edit, encrypt. Doing that +# by hand has two hazards: the decrypted copy is easy to leave on disk, and +# re-encrypting a fully decrypted file rewrites every value, which buries the one +# real change in a whole-file diff. +# +# This wrapper removes both. The plaintext copy lives only in a private temporary +# directory that is deleted on every exit path, and values you did not touch keep +# their original ciphertext. The committed file is only ever overwritten with +# content that is already encrypted, so no plaintext reaches the working tree. +# +# Reports key names only. A value here is a secret by definition. + +require "fileutils" +require "json" +require "open3" +require "shellwords" +require "tmpdir" + +require_relative "lib/cli_output" +require_relative "lib/ejson_secrets" + +REPO_ROOT = File.expand_path("..", __dir__) +SECRETS_DIR = File.join(REPO_ROOT, "config", "secrets") + +def available_names + Dir.glob("*.ejson", base: SECRETS_DIR).sort.map { |file| File.basename(file, ".ejson") } +end + +# Each file describes itself in a `_description` key. ejson never encrypts a key +# that starts with an underscore, and `ejson2env` reads only `environment`, so the +# text stays readable here without reaching the generated .env. +def description(name) + EjsonSecrets.load(File.join(SECRETS_DIR, "#{name}.ejson"))["_description"].to_s +rescue Errno::ENOENT, EjsonSecrets::InvalidFile + "" +end + +def help + names = available_names + width = names.map(&:length).max.to_i + files = names.map { |name| " #{name.ljust(width)} #{description(name)}".rstrip } + + [ + "Usage: dev secrets edit <#{names.join("|")}>", + "", + "Edits one encrypted storefront config file. Decrypts it to a private temporary", + "copy, opens $EDITOR, then encrypts again. Values you leave alone keep their", + "original ciphertext, so the diff shows only what you changed. No plaintext ever", + "reaches the working tree.", + "", + "Files in #{relative(SECRETS_DIR)}:", + *files, + "", + "A path to any .ejson file also works in place of a name.", + ].join("\n") +end + +def die_with_help(message) + warn CliOutput.red(message) + warn help + exit 1 +end + +def resolve(target) + found = [target, File.join(SECRETS_DIR, "#{target}.ejson")].find { |path| File.file?(path) } + return found if found + + CliOutput.die( + "No secrets file matches #{target}.", + hint: "pick one of: #{available_names.join(", ")}", + ) +end + +def decrypt(path) + plaintext, error, status = Open3.capture3("ejson", "decrypt", path) + return plaintext if status.success? + + CliOutput.die( + "Could not decrypt #{relative(path)}: #{error.strip}", + hint: "run `dev ejson persist-keypair #{relative(path)}` and paste the private key", + ) +end + +def encrypt(path) + _out, error, status = Open3.capture3("ejson", "encrypt", path) + return if status.success? + + CliOutput.die("Could not encrypt the edited values: #{error.strip}") +end + +def open_editor(path) + editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi" + return if system("#{editor} #{Shellwords.escape(path)}") + + CliOutput.die( + "#{editor} exited without saving, so nothing changed.", + hint: "set $EDITOR to the editor you want", + ) +end + +def edited_document(path) + loop do + open_editor(path) + + begin + return JSON.parse(File.read(path)) + rescue JSON::ParserError => error + message = "The edited file is not valid JSON: #{error.message.lines.first.strip}" + CliOutput.die(message, hint: "nothing was written, so rerun and edit again") unless $stdin.tty? + + warn CliOutput.red(message) + warn CliOutput.suggestion("press Return to keep editing, or Ctrl-C to discard the changes") + $stdin.gets + end + end +end + +def relative(path) + path.delete_prefix("#{REPO_ROOT}/") +end + +subcommand, target, *extra = ARGV + +if ["--help", "-h", "help"].include?(subcommand) + puts help + exit 0 +end + +die_with_help("dev secrets needs a subcommand.") if subcommand.nil? +die_with_help("dev secrets has no #{subcommand} subcommand.") unless subcommand == "edit" +die_with_help("dev secrets edit needs the name of a file to edit.") if target.nil? +die_with_help("dev secrets edit takes one file, but got #{extra.length + 1}.") unless extra.empty? + +path = resolve(target) +original = EjsonSecrets.load(path) +plaintext = decrypt(path) + +Dir.mktmpdir("secrets-edit") do |workspace| + scratch = File.join(workspace, "#{File.basename(path, ".ejson")}.json") + File.write(scratch, plaintext, perm: 0o600) + + decrypted = JSON.parse(plaintext) + edited = edited_document(scratch) + + # A new `_public_key` would encrypt only the values that changed. Every other + # value keeps ciphertext from the old key, so no single private key can then + # decrypt the whole file. Rotation has to re-encrypt every value. + if decrypted["_public_key"] != edited["_public_key"] + CliOutput.die( + "_public_key cannot be changed here, so #{relative(path)} is untouched.", + hint: "to rotate the key, decrypt the file, set the new `_public_key`, then encrypt the whole file", + ) + end + + changed = EjsonSecrets.changed_keys(decrypted: decrypted, edited: edited) + + if changed.empty? + puts "No changes, so #{relative(path)} is untouched." + next + end + + staged = File.join(workspace, "staged.ejson") + File.write(staged, JSON.pretty_generate( + EjsonSecrets.merge_edits(original: original, decrypted: decrypted, edited: edited), + ) + "\n", perm: 0o600) + + encrypt(staged) + + violations = EjsonSecrets.plaintext_violations(staged) + unless violations.empty? + CliOutput.die( + "ejson left #{violations.length} value(s) unencrypted, so #{relative(path)} is untouched: #{violations.join(", ")}", + hint: "check that `_public_key` in #{relative(path)} is intact", + ) + end + + File.write(path, File.read(staged)) + + puts "Updated #{relative(path)}. #{changed.length} value(s) changed:" + changed.each { |key| puts " #{key}" } + puts "Commit the file to share the change." +end diff --git a/scripts/secrets_setup b/scripts/secrets_setup new file mode 100755 index 000000000..2330bedef --- /dev/null +++ b/scripts/secrets_setup @@ -0,0 +1,248 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Reports whether this machine holds the EJSON private key for config/secrets, +# and installs it when asked. +# +# Two modes, because dev up and a person at a keyboard need different things: +# +# --check the dev up step. Says what the key is for and how to set it up, then +# waits for Return so dev up can finish. Never asks for the key, and +# always exits 0. +# (none) `dev secrets setup`. Asks for the key and installs it. +# +# Asking for the key belongs in the second mode only. A key pasted into a dev up +# step competes with the step spinner for the screen, and a mistake there costs +# the whole run. +# +# dev discards a met? step's stdout, and shows a meet step's stdout only when the +# step fails, so the prompt is written to and read from /dev/tty directly. When +# /dev/tty cannot be opened, the same text goes to stdout instead. +# +# Never prints the private key, and never prints a decrypted value. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SECRETS_DIR="${ROOT_DIR}/config/secrets" +KEYDIR="${EJSON_KEYDIR:-/opt/ejson/keys}" +KEY_LOCATION='the "Checkout Kit" 1Password vault -> "Repository secrets EJSON"' +KEY_PATTERN='^[0-9a-fA-F]{64}$' + +usage() { + cat <...] + +Installs the EJSON private key for config/secrets on this machine. + + --check Report only, and never ask for the key. dev up calls this as a met? + step, so it always exits 0. + +With no file arguments, every file in config/secrets is checked. +EOF +} + +mode="setup" +files=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + mode="check" + shift + ;; + -h | --help) + usage + exit 0 + ;; + -*) + echo "secrets_setup: unknown option $1" >&2 + usage >&2 + exit 1 + ;; + *) + files+=("$1") + shift + ;; + esac +done + +if [[ ${#files[@]} -eq 0 ]]; then + while IFS= read -r found; do + files+=("${found}") + done < <(find "${SECRETS_DIR}" -maxdepth 1 -name "*.ejson" -type f 2>/dev/null | sort) +fi + +[[ ${#files[@]} -eq 0 ]] && exit 0 + +public_key_of() { + sed -n 's/.*"_public_key"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]*\)".*/\1/p' "$1" | head -1 +} + +relative() { + echo "${1#"${ROOT_DIR}/"}" +} + +missing_keys=() +missing_files=() +first_missing_file="" + +for file in "${files[@]}"; do + public_key="$(public_key_of "${file}")" + [[ -z "${public_key}" ]] && continue + [[ -f "${KEYDIR}/${public_key}" ]] && continue + + if [[ ! " ${missing_keys[*]-} " == *" ${public_key} "* ]]; then + missing_keys+=("${public_key}") + missing_files+=("${file}") + fi + [[ -z "${first_missing_file}" ]] && first_missing_file="${file}" +done + +if [[ ${#missing_keys[@]} -eq 0 ]]; then + [[ "${mode}" == "check" ]] && exit 0 + echo "The storefront private key is already installed, so there is nothing to do." + exit 0 +fi + +if { exec 3>/dev/tty; } 2>/dev/null; then + can_prompt="true" +else + exec 3>&1 + can_prompt="false" +fi + +# Each spinner tick dev draws is `\e[1A \r \e[2C \r \e[1B`: up one line, +# two characters written at column 2, back down. So the line directly above the +# cursor is repainted several times a second, and the line holding the cursor +# collects the answer the terminal echoes. A message survives only two lines +# above the cursor, so one blank line goes after every question, and the spinner +# lands there instead. +spinner_guard() { + echo "" >&3 +} + +saved_tty_settings="" + +restore_tty() { + [[ -z "${saved_tty_settings}" ]] && return 0 + stty "${saved_tty_settings}" /dev/null || true + saved_tty_settings="" +} + +# Ctrl-C reaches every process in the foreground group, so under dev up it stops +# dev as well, and dev prints its own Interrupt line. Anything printed here would +# either repeat that or contradict it, so the interrupt is silent. +on_interrupt() { + restore_tty + exit 0 +} + +# A handler makes bash carry on where it left off, so TERM has to exit as well as +# put the terminal back. Without that, this script ignores TERM completely. +trap restore_tty EXIT +trap on_interrupt INT TERM + +print_manual_steps() { + cat >&3 <&3 + + [[ "${can_prompt}" != "true" ]] && exit 0 + + echo " Press Return to continue without them:" >&3 + spinner_guard + IFS= read -r _continue &3 + print_manual_steps + exit 1 +fi + +if [[ ! -d "${KEYDIR}" || ! -w "${KEYDIR}" ]]; then + echo "" >&3 + echo " The key directory ${KEYDIR} is missing or not writable." >&3 + print_manual_steps + exit 1 +fi + +# Echoing goes off before the question is printed, and stays off until this script +# exits. A paste can land between the question and the read, and the terminal +# echoes whatever arrives, so disabling it any later is too late: the key would +# already be on the screen. The EXIT and INT traps put the terminal back. +disable_echo() { + [[ -n "${saved_tty_settings}" ]] && return 0 + saved_tty_settings="$(stty -g &3 + spinner_guard + + key="" + IFS= read -r key &3 +} + +# Installs the key only against the public keys it actually decrypts, so a second +# file encrypted to a different key never receives the wrong private key. +key_installs_where_it_fits() { + local candidate="$1" index installed="false" + + for index in "${!missing_keys[@]}"; do + printf '%s' "${candidate}" | + ejson decrypt --key-from-stdin "${missing_files[$index]}" >/dev/null 2>&1 || continue + + printf '%s' "${candidate}" >"${KEYDIR}/${missing_keys[$index]}" + chmod 0440 "${KEYDIR}/${missing_keys[$index]}" + installed="true" + done + + [[ "${installed}" == "true" ]] +} + +disable_echo + +for attempt in 1 2; do + read_private_key + + if [[ ! "${key}" =~ ${KEY_PATTERN} ]]; then + echo " That is not an EJSON private key. It is 64 hexadecimal characters." >&3 + elif ! key_installs_where_it_fits "${key}"; then + echo " That key does not match the public key in config/secrets." >&3 + else + echo " Installed the key. Run \`dev up\` to finish setting the storefront up." >&3 + exit 0 + fi + + [[ "${attempt}" == "1" ]] && echo " Try again, or press Ctrl-C to stop." >&3 +done + +echo "" >&3 +print_manual_steps +exit 1 diff --git a/scripts/test/ejson_plaintext_guard_test.rb b/scripts/test/ejson_plaintext_guard_test.rb new file mode 100644 index 000000000..f82cf19df --- /dev/null +++ b/scripts/test/ejson_plaintext_guard_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "json" +require "tmpdir" +require_relative "../lib/ejson_secrets" + +class EjsonPlaintextGuardTest < Minitest::Test + ENCRYPTED = "EJ[1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA=:bbbb:cccc]" + + def test_fully_encrypted_file_has_no_violations + path = write_ejson( + "_public_key" => "e0f8b2c1", + "environment" => {"STOREFRONT_DOMAIN" => ENCRYPTED, "API_VERSION" => ENCRYPTED}, + ) + + assert_empty EjsonSecrets.plaintext_violations(path) + end + + def test_bare_string_value_is_a_violation + path = write_ejson("environment" => {"STOREFRONT_DOMAIN" => "leaked-store.example.com"}) + + assert_equal ["environment.STOREFRONT_DOMAIN"], EjsonSecrets.plaintext_violations(path) + end + + def test_violation_names_the_key_but_never_the_value + path = write_ejson("environment" => {"STOREFRONT_ACCESS_TOKEN" => "shpat_notarealtoken"}) + + violations = EjsonSecrets.plaintext_violations(path) + + refute_includes violations.join(" "), "shpat_notarealtoken" + end + + def test_underscore_prefixed_keys_are_exempt + path = write_ejson("_public_key" => "e0f8b2c1", "environment" => {"_comment" => "why this exists"}) + + assert_empty EjsonSecrets.plaintext_violations(path) + end + + def test_nested_objects_are_walked + path = write_ejson("environment" => {"nested" => {"DEEP_KEY" => "plaintext"}}) + + assert_equal ["environment.nested.DEEP_KEY"], EjsonSecrets.plaintext_violations(path) + end + + def test_non_string_leaves_are_violations_because_ejson_never_encrypts_them + path = write_ejson("environment" => {"SHOP_ID" => 12345}) + + assert_equal ["environment.SHOP_ID"], EjsonSecrets.plaintext_violations(path) + end + + def test_every_violation_is_reported_not_just_the_first + path = write_ejson("environment" => {"A_KEY" => "one", "B_KEY" => ENCRYPTED, "C_KEY" => "two"}) + + assert_equal ["environment.A_KEY", "environment.C_KEY"], EjsonSecrets.plaintext_violations(path) + end + + def test_malformed_json_raises_with_the_path + Dir.mktmpdir do |dir| + path = File.join(dir, "broken.ejson") + File.write(path, "{not json") + + error = assert_raises(EjsonSecrets::InvalidFile) { EjsonSecrets.plaintext_violations(path) } + assert_includes error.message, "broken.ejson" + end + end + + private + + def write_ejson(contents) + dir = Dir.mktmpdir + path = File.join(dir, "secrets.ejson") + File.write(path, JSON.pretty_generate(contents)) + path + end +end diff --git a/scripts/test/ejson_secrets_merge_test.rb b/scripts/test/ejson_secrets_merge_test.rb new file mode 100644 index 000000000..42d1698ea --- /dev/null +++ b/scripts/test/ejson_secrets_merge_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../lib/ejson_secrets" + +class EjsonSecretsMergeTest < Minitest::Test + CIPHER_A = "EJ[1:aaa:aaa:aaa]" + CIPHER_B = "EJ[1:bbb:bbb:bbb]" + + def test_unchanged_value_keeps_its_original_ciphertext + result = merge( + original: {"environment" => {"A" => CIPHER_A}}, + decrypted: {"environment" => {"A" => "same"}}, + edited: {"environment" => {"A" => "same"}}, + ) + + assert_equal CIPHER_A, result.dig("environment", "A") + end + + def test_changed_value_becomes_plaintext_for_ejson_to_encrypt + result = merge( + original: {"environment" => {"A" => CIPHER_A}}, + decrypted: {"environment" => {"A" => "old"}}, + edited: {"environment" => {"A" => "new"}}, + ) + + assert_equal "new", result.dig("environment", "A") + end + + def test_only_the_changed_key_loses_its_ciphertext + result = merge( + original: {"environment" => {"A" => CIPHER_A, "B" => CIPHER_B}}, + decrypted: {"environment" => {"A" => "old", "B" => "keep"}}, + edited: {"environment" => {"A" => "new", "B" => "keep"}}, + ) + + assert_equal({"A" => "new", "B" => CIPHER_B}, result["environment"]) + end + + def test_added_key_is_plaintext + result = merge( + original: {"environment" => {"A" => CIPHER_A}}, + decrypted: {"environment" => {"A" => "same"}}, + edited: {"environment" => {"A" => "same", "NEW" => "value"}}, + ) + + assert_equal "value", result.dig("environment", "NEW") + end + + def test_removed_key_is_absent_from_the_result + result = merge( + original: {"environment" => {"A" => CIPHER_A, "B" => CIPHER_B}}, + decrypted: {"environment" => {"A" => "one", "B" => "two"}}, + edited: {"environment" => {"A" => "one"}}, + ) + + refute_includes result["environment"].keys, "B" + end + + def test_key_order_follows_the_edited_file + result = merge( + original: {"environment" => {"A" => CIPHER_A, "B" => CIPHER_B}}, + decrypted: {"environment" => {"A" => "one", "B" => "two"}}, + edited: {"environment" => {"B" => "two", "A" => "one"}}, + ) + + assert_equal ["B", "A"], result["environment"].keys + end + + def test_public_key_passes_through_untouched + result = merge( + original: {"_public_key" => "abc", "environment" => {}}, + decrypted: {"_public_key" => "abc", "environment" => {}}, + edited: {"_public_key" => "abc", "environment" => {}}, + ) + + assert_equal "abc", result["_public_key"] + end + + def test_nested_objects_are_merged_at_every_level + result = merge( + original: {"environment" => {"nested" => {"A" => CIPHER_A, "B" => CIPHER_B}}}, + decrypted: {"environment" => {"nested" => {"A" => "old", "B" => "keep"}}}, + edited: {"environment" => {"nested" => {"A" => "new", "B" => "keep"}}}, + ) + + assert_equal({"A" => "new", "B" => CIPHER_B}, result.dig("environment", "nested")) + end + + def test_blank_value_filled_in_becomes_plaintext + result = merge( + original: {"environment" => {"TOKEN" => CIPHER_A}}, + decrypted: {"environment" => {"TOKEN" => ""}}, + edited: {"environment" => {"TOKEN" => "filled"}}, + ) + + assert_equal "filled", result.dig("environment", "TOKEN") + end + + def test_value_cleared_back_to_blank_becomes_plaintext + result = merge( + original: {"environment" => {"TOKEN" => CIPHER_A}}, + decrypted: {"environment" => {"TOKEN" => "was set"}}, + edited: {"environment" => {"TOKEN" => ""}}, + ) + + assert_equal "", result.dig("environment", "TOKEN") + end + + def test_changed_keys_are_reported_by_name + assert_equal( + ["environment.A"], + EjsonSecrets.changed_keys( + decrypted: {"environment" => {"A" => "old", "B" => "keep"}}, + edited: {"environment" => {"A" => "new", "B" => "keep"}}, + ), + ) + end + + def test_added_and_removed_keys_count_as_changes + assert_equal( + ["environment.GONE", "environment.NEW"], + EjsonSecrets.changed_keys( + decrypted: {"environment" => {"GONE" => "x"}}, + edited: {"environment" => {"NEW" => "y"}}, + ).sort, + ) + end + + private + + def merge(original:, decrypted:, edited:) + EjsonSecrets.merge_edits(original: original, decrypted: decrypted, edited: edited) + end +end diff --git a/scripts/test/secrets_edit_test.rb b/scripts/test/secrets_edit_test.rb new file mode 100644 index 000000000..b60b077f1 --- /dev/null +++ b/scripts/test/secrets_edit_test.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "fileutils" +require "json" +require "open3" +require "tmpdir" +require_relative "../lib/ejson_secrets" + +# Exercises scripts/secrets_edit end to end against a throwaway keypair, so the +# test never needs the repository's own private key. +class SecretsEditTest < Minitest::Test + REPO_ROOT = File.expand_path("../..", __dir__) + SCRIPT = File.join(REPO_ROOT, "scripts", "secrets_edit") + + def setup + skip "ejson is not installed" unless system("command -v ejson >/dev/null 2>&1") + + @dir = Dir.mktmpdir + @keydir = File.join(@dir, "keys") + Dir.mkdir(@keydir) + @public_key = generate_keypair + @path = File.join(@dir, "demo.ejson") + write_encrypted("STOREFRONT_DOMAIN" => "shop.example.com", "API_VERSION" => "2026-07", "BLANK" => "") + end + + def teardown + FileUtils.remove_entry(@dir) if @dir && File.exist?(@dir) + end + + def test_editing_one_value_stores_the_new_value + edit(%w[API_VERSION=2026-10]) + + assert_equal "2026-10", decrypt.fetch("API_VERSION") + end + + def test_the_file_stays_fully_encrypted + edit(%w[API_VERSION=2026-10]) + + assert_empty EjsonSecrets.plaintext_violations(@path) + end + + def test_untouched_values_keep_byte_identical_ciphertext + before = ciphertext + edit(%w[API_VERSION=2026-10]) + after = ciphertext + + assert_equal before.fetch("STOREFRONT_DOMAIN"), after.fetch("STOREFRONT_DOMAIN") + refute_equal before.fetch("API_VERSION"), after.fetch("API_VERSION") + end + + def test_filling_a_blank_value_works + edit(%w[BLANK=now-set]) + + assert_equal "now-set", decrypt.fetch("BLANK") + end + + def test_saving_without_a_change_leaves_the_file_byte_identical + before = File.read(@path) + edit([]) + + assert_equal before, File.read(@path) + end + + def test_saving_without_a_change_says_so + _out, status = edit([]) + + assert_equal 0, status + end + + def test_the_changed_key_is_reported_by_name + out, = edit(%w[API_VERSION=2026-10]) + + assert_includes out, "API_VERSION" + end + + def test_the_new_value_is_never_printed + out, = edit(%w[STOREFRONT_ACCESS_TOKEN=shpat_notarealtoken]) + + refute_includes out, "shpat_notarealtoken" + end + + def test_an_editor_that_fails_leaves_the_file_untouched + before = File.read(@path) + _out, status = run_script(editor: exiting_editor) + + assert_equal 1, status + assert_equal before, File.read(@path) + end + + def test_invalid_json_leaves_the_file_untouched + before = File.read(@path) + out, status = run_script(editor: corrupting_editor) + + assert_equal 1, status + assert_equal before, File.read(@path) + assert_includes out, "JSON" + end + + def test_no_plaintext_survives_on_disk + edit(%w[API_VERSION=2026-10]) + edited_path = File.read(File.join(@dir, "editor_argv")).strip + + refute_path_exists edited_path + end + + def test_help_lists_every_available_file_by_name + out, status = run_bare("--help") + + assert_equal 0, status + assert_includes out, "demo" + assert_includes out, "e2e" + end + + def test_help_describes_what_each_file_configures + out, = run_bare("--help") + + assert_includes out, "sample apps" + assert_includes out, "end-to-end" + end + + def test_help_shows_the_usage_line + out, = run_bare("--help") + + assert_includes out, "dev secrets edit" + end + + def test_no_arguments_shows_the_same_help_and_fails + out, status = run_bare + + assert_equal 1, status + assert_includes out, "demo" + assert_includes out, "e2e" + end + + def test_edit_without_a_name_lists_the_choices + out, status = run_bare("edit") + + assert_equal 1, status + assert_includes out, "demo" + assert_includes out, "e2e" + end + + def test_an_unknown_subcommand_names_itself_and_shows_help + out, status = run_bare("delete", "demo") + + assert_equal 1, status + assert_includes out, "delete" + assert_includes out, "dev secrets edit" + end + + def test_an_unknown_name_is_rejected + out, status = run_script(editor: setting_editor([]), target: "nope") + + assert_equal 1, status + assert_includes out, "nope" + end + + def test_a_missing_file_is_rejected + out, status = run_script(editor: setting_editor([]), target: File.join(@dir, "absent.ejson")) + + assert_equal 1, status + assert_includes out, "absent.ejson" + end + + def test_a_missing_private_key_names_the_command_that_installs_it + out, status = run_script(editor: setting_editor([]), keydir: empty_keydir) + + assert_equal 1, status + assert_includes out, "dev ejson persist-keypair" + end + + def test_changing_the_public_key_is_rejected + before = File.read(@path) + out, status = run_script(editor: rekeying_editor) + + assert_equal 1, status + assert_equal before, File.read(@path) + assert_includes out, "_public_key" + assert_includes out, "cannot be changed" + end + + private + + def edit(assignments) + out, status = run_script(editor: setting_editor(assignments)) + assert_equal 0, status, "secrets_edit failed:\n#{out}" + [out, status] + end + + def run_bare(*args) + out, status = Open3.capture2e({"NO_COLOR" => "1"}, SCRIPT, *args, chdir: REPO_ROOT) + [out, status.exitstatus] + end + + def run_script(editor:, target: @path, keydir: @keydir) + env = {"EJSON_KEYDIR" => keydir, "EDITOR" => editor, "NO_COLOR" => "1"} + out, status = Open3.capture2e(env, SCRIPT, "edit", target, chdir: REPO_ROOT) + [out, status.exitstatus] + end + + def setting_editor(assignments) + fake_editor(<<~RUBY) + values = #{assignments.inspect}.to_h { |pair| pair.split("=", 2) } + document = JSON.parse(File.read(path)) + document["environment"].merge!(values) + File.write(path, JSON.pretty_generate(document)) + RUBY + end + + def exiting_editor + fake_editor("exit 3") + end + + def corrupting_editor + fake_editor('File.write(path, "{ not json")') + end + + def rekeying_editor + fake_editor(<<~RUBY) + document = JSON.parse(File.read(path)) + document["_public_key"] = "0" * 64 + File.write(path, JSON.pretty_generate(document)) + RUBY + end + + def empty_keydir + File.join(@dir, "empty-keys").tap { |path| Dir.mkdir(path) } + end + + def fake_editor(body) + path = File.join(@dir, "editor_#{body.hash.abs}") + File.write(path, <<~RUBY) + #!/usr/bin/env ruby + require "json" + path = ARGV.fetch(0) + File.write(#{File.join(@dir, "editor_argv").inspect}, path) + #{body} + RUBY + File.chmod(0o755, path) + path + end + + def generate_keypair + out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "keygen", "--write") + raise "keygen failed: #{out}" unless status.success? + + out.strip.split.last + end + + def write_encrypted(environment) + File.write(@path, JSON.pretty_generate("_public_key" => @public_key, "environment" => environment)) + out, status = Open3.capture2e("ejson", "encrypt", @path) + raise "encrypt failed: #{out}" unless status.success? + end + + def decrypt + out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "decrypt", @path) + raise "decrypt failed: #{out}" unless status.success? + + JSON.parse(out).fetch("environment") + end + + def ciphertext + JSON.parse(File.read(@path)).fetch("environment") + end +end diff --git a/scripts/test/secrets_setup_test.rb b/scripts/test/secrets_setup_test.rb new file mode 100644 index 000000000..0ea43d670 --- /dev/null +++ b/scripts/test/secrets_setup_test.rb @@ -0,0 +1,453 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "fileutils" +require "json" +require "open3" +require "pty" +require "shellwords" +require "tmpdir" + +# Exercises scripts/secrets_setup against a throwaway keypair, so the test never +# needs the repository's own private key. The interactive cases run under a real +# pty, because the script talks to /dev/tty rather than to stdout. +# +# Two modes are covered separately. --check is the dev up step, which reports and +# waits for Return. No flag is `dev secrets setup`, which asks for the key. +class SecretsSetupTest < Minitest::Test + REPO_ROOT = File.expand_path("../..", __dir__) + SCRIPT = File.join(REPO_ROOT, "scripts", "secrets_setup") + INTERRUPT = 3.chr + + def setup + skip "ejson is not installed" unless system("command -v ejson >/dev/null 2>&1") + + @dir = Dir.mktmpdir + @keydir = File.join(@dir, "keys") + Dir.mkdir(@keydir) + @public_key = generate_keypair + @private_key = File.read(key_path).strip + @path = File.join(@dir, "demo.ejson") + write_encrypted("STOREFRONT_DOMAIN" => "shop.example.com") + File.delete(key_path) + end + + def teardown + FileUtils.chmod(0o700, @keydir) if @keydir && File.directory?(@keydir) + FileUtils.remove_entry(@dir) if @dir && File.exist?(@dir) + end + + def test_an_installed_key_prints_nothing + install_private_key + out, status = run_check + + assert_equal 0, status + assert_empty out.strip + end + + def test_a_missing_key_without_a_terminal_names_the_setup_command + out, status = run_check + + assert_equal 0, status + assert_includes out, "dev secrets setup" + end + + def test_a_missing_key_without_a_terminal_reports_no_error + out, = run_check + + refute_includes out, "/dev/tty" + refute_includes out, "line " + end + + def test_a_missing_key_without_a_terminal_installs_nothing + run_check + + assert_empty Dir.children(@keydir) + end + + def test_the_step_says_what_the_key_is_for + out = check_on_terminal("\n") + + assert_match(/e2e/i, out) + assert_match(/sample/i, out) + assert_includes out, "one time" + end + + def test_the_step_names_the_way_out_of_dev_up + out = check_on_terminal("\n") + + assert_includes out, "Ctrl-C" + assert_includes out, "dev secrets setup" + assert_includes out, "dev up" + end + + def test_the_step_asks_one_question_and_names_its_answer + out = check_on_terminal("\n") + questions = out.delete("\r").lines.select { |line| question?(line) } + + assert_equal 1, questions.length + assert_includes questions.first, "Return" + end + + # A dev spinner tick repaints the line directly above the cursor, and the + # terminal echoes the answer onto the cursor line itself, so a question is only + # safe two lines up. Keep the blank line below every question. + def test_a_blank_line_stands_between_each_question_and_the_cursor + out = check_on_terminal("\n").delete("\r") + questions = out.lines.each_index.select { |index| question?(out.lines[index]) } + + refute_empty questions + questions.each { |index| assert_equal "\n", out.lines[index + 1] } + end + + # The tick that closes the step lands on the line above the cursor too, so the + # step prints nothing after the answer. There is no last message to clip. + def test_the_step_prints_nothing_after_the_answer + assert check_on_terminal("\n").delete("\r").end_with?("\n\n") + end + + # The step protects no secret, so echoing stays on. Without it the cursor and + # the typing are both invisible, and nobody can tell the answer registered. + def test_the_step_shows_what_is_typed + out = check_on_terminal("k\n") + + assert_includes out, "k" + end + + def test_the_step_never_asks_for_the_key + out = check_on_terminal("\n") + + refute_includes out, "Paste" + refute_includes out, "1Password" + end + + def test_the_step_installs_nothing_even_when_a_key_is_typed + check_on_terminal("#{@private_key}\n") + + assert_empty Dir.children(@keydir) + end + + def test_the_step_continues_after_the_answer + _out, status = check_on_terminal_with_status("\n") + + assert_equal 0, status + end + + # Ctrl-C reaches dev as well, so dev up aborts and prints its own Interrupt + # line. Anything printed here would contradict it. + # The terminal itself echoes ^C, so the check is that the script adds nothing of + # its own after the question. + def test_control_c_in_the_step_says_nothing_and_succeeds + out, status = check_on_terminal_with_status(INTERRUPT) + + after_question = out.delete("\r").split("continue without them:\n").last.to_s + + assert_equal 0, status + assert_equal "", after_question.gsub("^C", "").strip + assert_empty Dir.children(@keydir) + end + + def test_setup_says_when_the_key_is_already_installed + install_private_key + out, status = run_setup + + assert_equal 0, status + assert_includes out, "already installed" + end + + def test_setup_says_where_to_find_the_key + out = setup_on_terminal("#{@private_key}\n") + + assert_includes out, "1Password" + assert_includes out, "Repository secrets EJSON" + end + + # Echo is off while the key is read, so the terminal never echoes the Return that + # ends the answer, and the cursor stays on the question line. Setup writes that + # line break itself, or the next message continues the question. + def test_an_installed_key_is_reported_below_the_question + assert_answer_stands_between_the_question_and_the_message("#{@private_key}\n") + end + + def test_a_refused_key_is_reported_below_the_question + assert_answer_stands_between_the_question_and_the_message("not-a-key\nnot-a-key\n") + end + + def test_setup_installs_a_pasted_key + setup_on_terminal("#{@private_key}\n") + + assert_path_exists key_path + end + + def test_setup_reports_the_next_command + out = setup_on_terminal("#{@private_key}\n") + + assert_includes out, "dev up" + end + + def test_an_installed_key_is_not_readable_by_everyone + setup_on_terminal("#{@private_key}\n") + + assert_equal "0440", format("%04o", File.stat(key_path).mode & 0o7777) + end + + def test_an_installed_key_decrypts_the_file + setup_on_terminal("#{@private_key}\n") + + assert_equal "shop.example.com", decrypt.fetch("STOREFRONT_DOMAIN") + end + + def test_a_key_is_installed_only_where_it_decrypts + other_public_key = another_public_key + other = write_second_file(other_public_key) + setup_on_terminal("#{@private_key}\n", files: [@path, other]) + + assert_path_exists key_path + refute_path_exists key_path(other_public_key) + end + + def test_the_private_key_is_never_printed + out = setup_on_terminal("#{@private_key}\n") + + refute_includes out, @private_key + end + + def test_setup_succeeds_after_installing_the_key + _out, status = setup_on_terminal_with_status("#{@private_key}\n") + + assert_equal 0, status + end + + def test_a_key_that_is_not_hexadecimal_is_refused + setup_on_terminal("not-a-key\nnot-a-key\n") + + assert_empty Dir.children(@keydir) + end + + def test_a_key_of_the_right_shape_that_cannot_decrypt_is_refused + setup_on_terminal("#{"0" * 64}\n#{"0" * 64}\n") + + assert_empty Dir.children(@keydir) + end + + def test_a_refused_key_falls_back_to_the_install_command + out, status = setup_on_terminal_with_status("#{"0" * 64}\n#{"0" * 64}\n") + + assert_equal 1, status + assert_includes out, "dev ejson persist-keypair" + end + + def test_control_c_in_setup_says_nothing_and_installs_nothing + _out, status = setup_on_terminal_with_status(INTERRUPT) + + assert_equal 0, status + assert_empty Dir.children(@keydir) + end + + def test_setup_without_a_terminal_names_the_install_command + out, status = run_setup + + assert_equal 1, status + assert_includes out, "dev ejson persist-keypair" + end + + def test_setup_with_an_unwritable_keydir_names_the_install_command + FileUtils.chmod(0o500, @keydir) + out, status = setup_on_terminal_with_status("") + + assert_equal 1, status + assert_includes out, "dev ejson persist-keypair" + end + + def test_setup_with_a_missing_keydir_names_the_install_command + out, status = setup_on_terminal_with_status("", keydir: File.join(@dir, "absent")) + + assert_equal 1, status + assert_includes out, "dev ejson persist-keypair" + end + + private + + # One blank line keeps the spinner off the question, and one stands in for the + # Return the terminal did not echo. + def assert_answer_stands_between_the_question_and_the_message(answer) + lines = setup_on_terminal(answer).delete("\r").lines + question = lines.index { |line| line.include?("stays hidden:") } + message = (question + 1...lines.length).find { |index| !lines[index].strip.empty? } + + assert_equal ["\n", "\n"], lines[question + 1...message] + end + + def question?(line) + line.rstrip.end_with?(":") + end + + # A question, then the blank line that keeps the spinner off it, then the read. + def waiting_for_input?(output) + text = output.delete("\r") + text.end_with?("\n\n") && question?(text.lines[-2].to_s) + end + + def key_path(public_key = @public_key) + File.join(@keydir, public_key) + end + + def env(keydir: @keydir) + {"EJSON_KEYDIR" => keydir, "NO_COLOR" => "1"} + end + + def run_check(keydir: @keydir) + capture(["--check", @path], keydir: keydir) + end + + def run_setup(keydir: @keydir) + capture([@path], keydir: keydir) + end + + def capture(arguments, keydir: @keydir) + out, status = Open3.capture2e(env(keydir: keydir), SCRIPT, *arguments, chdir: REPO_ROOT) + [out, status.exitstatus] + end + + def check_on_terminal(input, files: [@path]) + check_on_terminal_with_status(input, files: files).first + end + + def check_on_terminal_with_status(input, files: [@path], keydir: @keydir) + on_terminal(["--check", *files], input, keydir: keydir) + end + + def setup_on_terminal(input, files: [@path]) + setup_on_terminal_with_status(input, files: files).first + end + + def setup_on_terminal_with_status(input, files: [@path], keydir: @keydir) + on_terminal(files, input, keydir: keydir) + end + + # Types one line at a time, and only once a question is waiting, which is what + # a person at a keyboard does. + def on_terminal(arguments, input, keydir: @keydir) + output = +"" + status = nil + + PTY.spawn(env(keydir: keydir), SCRIPT, *arguments, chdir: REPO_ROOT) do |reader, writer, pid| + lines(input).each do |line| + wait_for_prompt(reader, output, after: output.length) + writer.write(line) + end + read_until_closed(reader, output) + status = wait_for_exit(pid) + end + + [output, status] + end + + # Every write ends with a newline, including a bare Ctrl-C. The line discipline + # needs the terminator before it hands anything to the script. + def lines(input) + return [] if input.empty? + + parts = input.split("\n", -1) + parts.pop if parts.last.empty? + parts.map { |line| "#{line}\n" } + end + + def wait_for_prompt(reader, output, after: 0, timeout: 10) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + + until output.length > after && waiting_for_input?(output) + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + raise "no prompt within #{timeout}s. Saw:\n#{output}" if remaining <= 0 + break unless IO.select([reader], nil, nil, remaining) + + output << reader.readpartial(4096) + end + rescue Errno::EIO, EOFError + nil + end + + # Reads to end of output, which the pty reports as EIO once the script exits. + # Draining before reaping keeps the exit quick. The deadline is a backstop, so a + # script that waits for input the test never sends fails instead of hanging. + def read_until_closed(reader, output, timeout: 10) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + + loop do + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + break if remaining <= 0 + break unless IO.select([reader], nil, nil, remaining) + + output << reader.readpartial(4096) + end + rescue Errno::EIO, EOFError + nil + end + + def wait_for_exit(pid, timeout: 5) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + + while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + reaped, status = Process.waitpid2(pid, Process::WNOHANG) + return status.exitstatus if reaped + + sleep 0.05 + end + + ["TERM", "KILL"].each do |signal| + Process.kill(signal, pid) + 3.times do + reaped, status = Process.waitpid2(pid, Process::WNOHANG) + return status.exitstatus if reaped + + sleep 0.1 + end + end + + nil + rescue Errno::ECHILD, Errno::ESRCH + nil + end + + def install_private_key + File.write(key_path, @private_key) + File.chmod(0o440, key_path) + end + + def generate_keypair + out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "keygen", "--write") + raise "keygen failed: #{out}" unless status.success? + + out.strip.split.last + end + + # A public key whose private half never reaches any keydir the script can read. + def another_public_key + out, status = Open3.capture2e("ejson", "keygen") + raise "keygen failed: #{out}" unless status.success? + + out.lines.fetch(1).strip + end + + def write_second_file(public_key) + path = File.join(@dir, "e2e.ejson") + File.write(path, JSON.pretty_generate("_public_key" => public_key, "environment" => {"SHOP" => "other.example.com"})) + out, status = Open3.capture2e("ejson", "encrypt", path) + raise "encrypt failed: #{out}" unless status.success? + + path + end + + def write_encrypted(environment) + File.write(@path, JSON.pretty_generate("_public_key" => @public_key, "environment" => environment)) + out, status = Open3.capture2e("ejson", "encrypt", @path) + raise "encrypt failed: #{out}" unless status.success? + end + + def decrypt + out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "decrypt", @path) + raise "decrypt failed: #{out}" unless status.success? + + JSON.parse(out).fetch("environment") + end +end