Skip to content
Merged
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
23 changes: 23 additions & 0 deletions config/secrets/demo.ejson
Original file line number Diff line number Diff line change
@@ -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]"
}
}
17 changes: 17 additions & 0 deletions config/secrets/e2e.ejson
Original file line number Diff line number Diff line change
@@ -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]"
}
}
17 changes: 17 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: Check storefront secrets
met?: ./scripts/secrets_setup --check
meet: "true"
- 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 @@ -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 <edit <demo|e2e>|setup>"
aliases: [secret]
syntax: "<edit <demo|e2e>|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 [<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",
)
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