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