Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions config/secrets/demo.ejson
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"_public_key": "58b34b9a2be67c206423293ba2c0317e6fbe8f727f7ac124ff62349d36fa0136",
"_description": "Storefront config for the sample apps. Generates .env.",
"environment": {
"STOREFRONT_DOMAIN": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:i1x9oDbTeqzwb3zq/o00LBihszBiNQez:BwQB1jy6AeUd4Fv8oTmEctbRvMwW+Nv/1lwVRsY40wm3BUT8H4tNvwcaFg==]",
"STOREFRONT_ACCESS_TOKEN": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:0GjD/lYKCJp+g6tqGUhXq5eis4jLnKs5:k3zhxUrWpsAX7WM4FSNepviZq+8WxJlFjCmPzYr1SD/NWQWlXfv3l/sO+FWw+kIp]",
"STOREFRONT_MERCHANT_IDENTIFIER": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:oTYrF1rKP4BYrP9TlBpTwvbWiV0F4sdj:AddPHOypJlCxip0fCoEQ5w==]",
"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=]",
"CUSTOM_USER_AGENT": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:KWOMHH5wKcOTgBtJTlApjAs9PgQmyGxt:NcBxjxWoxrlfQ5y74b1xqw==]",
"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]"
}
}
29 changes: 29 additions & 0 deletions config/secrets/e2e.ejson
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"_public_key": "58b34b9a2be67c206423293ba2c0317e6fbe8f727f7ac124ff62349d36fa0136",
"_description": "Storefront config for the end-to-end suite. Generates e2e/.env.",
"environment": {
"STOREFRONT_DOMAIN": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:t7QHs7VjgNiyYUm3uCXDbZT6qRuPVqHm:UFeHKVA5YLp+s3LGuJc+Ng==]",
"STOREFRONT_ACCESS_TOKEN": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:tKJfqdes2onPLDTu0XzxFCJd3n8U3Ym1:Tu1VEx/bdIOjX+gKJoGqRQ==]",
"STOREFRONT_MERCHANT_IDENTIFIER": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:Tmbn7/mUZdp4ABBBr1uzONX2W7BIT61v:+CA1PRx+AYCuDNYyz0DsSg==]",
"API_VERSION": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:s/G4xIw0Jj6owov7iFEdLzZGkEKG11Gy:rxr17ZMK/zb5tJW+Xcy+Iw==]",
"CUSTOMER_ACCOUNT_API_CLIENT_ID": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:iyctpO56xkE4ifWOYQ1z61p7HLpEpd+N:oIP7VcQfhb47nxWgWwXw0Q==]",
"CUSTOMER_ACCOUNT_API_SHOP_ID": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:jc+blwWAxr9IecDsKmMS7XI+nXFnOQmn:8yYkPcIbCFK5VTPQU1X/PQ==]",
"CUSTOMER_ACCOUNT_API_VERSION": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:03adF/PED1CroHFOZ6CxLRtAING1PtZz:0i9A1Ma0Me2LnrbBZqjioQ==]",
"CUSTOM_USER_AGENT": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:01oqeF8ilOQA+QGrx9qUt854q9Pk3HxX:2bIhqQKxs6gjbujCj3OdCw==]",
"EMAIL": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:JpJ+Bsir4FHW8iPioAF+tu1pcnlTOPBw:fjqdMe95d9Rhw5FULW6i5w==]",
"ADDRESS_1": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:KgN2UMP8PFsuXTEsQVLcuLAxKc7PQNRx:+LpJOgLj0nQxR9IPtir3wg==]",
"ADDRESS_2": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:gbJBMAfohmLPqTDw5hHtydTzIbJU8nKp:2np/dGu/CifY1GbASQ7OxA==]",
"CITY": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:pSMs3x3GgjshpolSXHThbTaDrTyvqvNw:/FbYbSUGlrXF7QTHuaVAjw==]",
"COMPANY": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:Vkd7KeMkH3Bc8ZfKuefhp0d1Cxh/4/Lg:SIEOvLAytAhkwzYYmD/Y6A==]",
"COUNTRY": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:L5biPGeiruVVlC+HA3wBEFyXbUhSCocF:q4UUsXR600c4dAgPZd/P2g==]",
"FIRST_NAME": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:rcx+RUA+sO0uKmLnwHRWYHdYaxXArU4U:dl/N+wIbLFMClMB56DeInw==]",
"LAST_NAME": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:UrEeG4ldQmZUNjwBz2FPsNVf/0fnwgOr:TJi5m6g25OM3IpsCfflzRg==]",
"PROVINCE": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:p3Nw0pHINd8dl8jMcM/peIeIaKJbbBxC:9vGrvrYDTB1hR4FwgSsZeA==]",
"ZIP": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:KSnHFbp4RwAiH9I+AvG3KxeKnxVlNWss:Z/sJ9x2m8UUy1meq5OXp0A==]",
"PHONE": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:7MM7TRau24nXX5pEp7Uvh6Zjwv5vg4tb:KIqeXyo87ODzfeAXMDeyTQ==]",
"E2E_CUSTOMER_ACCOUNT_EMAIL": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:ifXGz1j3684QOLLBsuedakdWWzaZ213f:SEFnXv+H2dVXI2ovGnUw3g==]",
"E2E_CUSTOMER_ACCOUNT_CODE": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:ERDn1UNXiynu/+aJJFbKF9tyBEODmeYL:blsslG3ZPqz1bRZGb5UXHA==]",
"BROWSERSTACK_USERNAME": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:VehmJMS7+WrEdU+9Ex9EsYbcMN2vMLal:Wrsea+e/rFoKJaZfRrm6nA==]",
"BROWSERSTACK_ACCESS_KEY": "EJ[1:pZceWRGpXAkHpFe8oMFYA83Qca49hwtdnAqGykd9pHM=:jiPwm2STMm22ohQvXlRwcauhCMdgFcy8:1T66YVRkrRAT0d8NFIZfiw==]"
}
}
12 changes: 12 additions & 0 deletions dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ up:
- swiftformat
- sccache
- bitrise
- ejson
- ejson2env
- ruby
- xcode:
version: "26.2"
Expand All @@ -25,6 +27,10 @@ up:
- protocol
- platforms/react-native
- platforms/web
- custom:
name: Install the ejson private key
met?: ./scripts/install_ejson_key --check
meet: ./scripts/install_ejson_key
- custom:
name: Copy root env into worktree
met?: ./scripts/copy_worktree_env --check
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +97,11 @@ 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 an encrypted storefront config file. Usage: dev secrets edit <demo|e2e>"
syntax: "edit <demo|e2e>"
run: ./scripts/secrets_edit "$@"

tophat:
desc: "Install a PR's build to a device via Tophat. Usage: dev tophat [<pr-number-or-url>] [<target>]"
syntax: "[<pr-number-or-url>] [<target>]"
Expand Down
44 changes: 44 additions & 0 deletions scripts/ejson_lint
Original file line number Diff line number Diff line change
@@ -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",
)
95 changes: 95 additions & 0 deletions scripts/install_ejson_key
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash

set -euo pipefail

# Installs the ejson private key that decrypts config/secrets/*.ejson.
#
# The public key lives in the committed ejson files. The matching private key
# lives in GCP Secret Manager, named after the public key, and every Shopify
# engineer can read it. This script copies it into the local ejson keydir.
#
# Runs automatically as a `dev up` step. With --check it reports (without
# installing) whether a fetch is needed: exit 0 when nothing can or should be
# done, and non-zero when a fetch would succeed, so it serves as a met? probe.
#
# Anyone without access to the GCP project has no key and needs none. They set
# their own values in .env by hand, and every step here exits 0 without acting.
#
# This script prints public key names and status only. The private key travels
# from gcloud to the keydir through a redirect, so it never enters an argument
# list, an environment variable, or this output.

PROJECT="shopify-ejson-keys-dev"
KEYDIR="${EJSON_KEYDIR:-/opt/ejson/keys}"

mode="install"
if [[ "${1:-}" == "--check" ]]; then
mode="check"
elif [[ $# -gt 0 ]]; then
echo "Usage: scripts/install_ejson_key [--check]" >&2
exit 1
fi

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

public_keys() {
local file
for file in "${REPO_ROOT}"/config/secrets/*.ejson; do
[[ -e "${file}" ]] || continue
sed -n 's/.*"_public_key"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "${file}"
done | sort -u
}

skip() {
# Reported, not fatal. A missing key is a supported state: the values in .env
# are then whatever the developer put there.
echo "install_ejson_key: $1"
echo "install_ejson_key: leaving the keydir alone; .env stays under your control."
exit 0
}

missing=()
while IFS= read -r pub; do
[[ -n "${pub}" ]] || continue
[[ -f "${KEYDIR}/${pub}" ]] || missing+=("${pub}")
done < <(public_keys)

if [[ ${#missing[@]} -eq 0 ]]; then
[[ "${mode}" == "check" ]] && exit 0
echo "install_ejson_key: every public key already has a private key in ${KEYDIR}."
exit 0
fi

if ! command -v gcloud >/dev/null 2>&1; then
skip "gcloud is not installed, so the private key cannot be fetched"
fi

if [[ ! -d "${KEYDIR}" ]] || [[ ! -w "${KEYDIR}" ]]; then
skip "${KEYDIR} is missing or not writable; run \`dev ejson normalize-keydir\` first"
fi

for pub in "${missing[@]}"; do
if ! gcloud secrets describe "${pub}" --project="${PROJECT}" >/dev/null 2>&1; then
skip "no readable secret named ${pub} in ${PROJECT}"
fi
done

if [[ "${mode}" == "check" ]]; then
echo "install_ejson_key: missing ${#missing[@]} private key(s) in ${KEYDIR}"
exit 1
fi

for pub in "${missing[@]}"; do
tmp="$(mktemp)"
trap 'rm -f "${tmp}"' EXIT

if ! gcloud secrets versions access latest --secret="${pub}" --project="${PROJECT}" >"${tmp}" 2>/dev/null; then
rm -f "${tmp}"
skip "could not read the latest version of ${pub} in ${PROJECT}"
fi

# ejson keygen writes 0440, so match it rather than the looser default umask.
chmod 440 "${tmp}"
mv "${tmp}" "${KEYDIR}/${pub}"
echo "install_ejson_key: installed ${pub}"
done
93 changes: 93 additions & 0 deletions scripts/lib/ejson_secrets.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading