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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
## [Unreleased]

### Fixed

- **Play upload keystore no longer bakes a trailing newline into the stored
password** (MOB-71). `Mix.shell().prompt/1` returns the whole input line
including the trailing `\n`, and `MobDev.GooglePlay.SetupWizard.generate_keystore/2`
passed that value verbatim to both `keytool -storepass` and
`android/keystore.properties`. The keystore then contained a password
ending in `\n` that Gradle read literally on every subsequent release
signing — a value nobody could re-type at the next Play upload, and only
discovered when a signed AAB fails to upload with a stack trace deep
inside `bundletool`. The three prompts (passphrase, name, org) are now
trimmed at the source (`prompt_trimmed/2`), and
`keystore_properties_content/1` is extracted and public so the trim is
covered by revert-verified unit tests.

### Changed

- **`mix mob.deploy` now freezes an explicit target set before doing work**
Expand Down
42 changes: 33 additions & 9 deletions lib/mob_dev/google_play/setup_wizard.ex
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ defmodule MobDev.GooglePlay.SetupWizard do

defp generate_keystore(shell, path) do
shell.info("")
passphrase = shell.prompt(" Keystore passphrase (remember this — back it up!): ")
name = shell.prompt(" Your full name: ")
org = shell.prompt(" Organisation (or your name): ")
passphrase = prompt_trimmed(shell, " Keystore passphrase (remember this — back it up!): ")
name = prompt_trimmed(shell, " Your full name: ")
org = prompt_trimmed(shell, " Organisation (or your name): ")

args = [
"-genkey",
Expand Down Expand Up @@ -169,15 +169,39 @@ defmodule MobDev.GooglePlay.SetupWizard do

defp write_keystore_properties(path, passphrase) do
unless File.exists?(path) do
File.write!(path, """
storeFile=upload_jks.keystore
storePassword=#{passphrase}
keyAlias=upload
keyPassword=#{passphrase}
""")
File.write!(path, keystore_properties_content(passphrase))
end
end

@doc """
Render the `android/keystore.properties` file body for `passphrase`.

The passphrase is trimmed so a trailing newline from `Mix.shell().prompt/1`
(or leading/trailing whitespace pasted from a password manager) cannot be
baked into the stored password — Gradle's signing config reads the file
literally, so a `storePassword=secret\\n` line signs releases with a
password nobody can retype on the next upload. See MOB-71.
"""
@spec keystore_properties_content(binary()) :: binary()
def keystore_properties_content(passphrase) when is_binary(passphrase) do
trimmed = String.trim(passphrase)

"""
storeFile=upload_jks.keystore
storePassword=#{trimmed}
keyAlias=upload
keyPassword=#{trimmed}
"""
end

# `Mix.shell().prompt/1` includes the trailing newline in its return value;
# a raw passphrase carries that newline everywhere it is used — the keytool
# `-storepass` arg, the `keystore.properties` file, the -dname CN/O fields.
# Trimming at the source is safer than fixing each call site downstream.
defp prompt_trimmed(shell, question) do
shell.prompt(question) |> String.trim()
end

defp step_manual_account_creation(shell, dry?) do
shell.info("")
shell.info(yellow() <> "── Manual prerequisite steps ──" <> reset())
Expand Down
39 changes: 39 additions & 0 deletions test/mob_dev/google_play/setup_wizard_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule MobDev.GooglePlay.SetupWizardTest do

alias MobDev.GooglePlay.CloudSetup
alias MobDev.GooglePlay.PlaySetup
alias MobDev.GooglePlay.SetupWizard

# ── Key filename derivation ──────────────────────────────────────────────────
# Mirrors the logic in SetupWizard.default_key_filename/1.
Expand Down Expand Up @@ -130,4 +131,42 @@ defmodule MobDev.GooglePlay.SetupWizardTest do
end
end
end

# ── keystore.properties passphrase handling (MOB-71) ─────────────────────────

describe "keystore_properties_content/1" do
test "does NOT bake a trailing newline from Mix.shell().prompt/1 into the stored password" do
# `Mix.shell().prompt/1` returns the whole line including the trailing
# \n. Before MOB-71 that newline flowed straight into `-storepass` at
# keytool time AND into `keystore.properties`, so Gradle later signed
# every release with `secret\n` — a password nobody can retype on the
# next Play upload. Revert the `String.trim/1` in
# `keystore_properties_content/1` and this assertion fails: the file
# would contain `storePassword=secret\n\nkeyAlias=...`.
content = SetupWizard.keystore_properties_content("secret\n")

assert content =~ ~r/^storePassword=secret$/m
assert content =~ ~r/^keyPassword=secret$/m
refute content =~ "storePassword=secret\n\n"
end

test "strips leading/trailing whitespace so a pasted password does not carry surrounding blanks" do
# Password managers often add a leading space when auto-typing into a
# terminal prompt; users occasionally add a trailing space by habit.
# Either would produce a keystore Gradle can never open again.
content = SetupWizard.keystore_properties_content(" secret ")

assert content =~ ~r/^storePassword=secret$/m
assert content =~ ~r/^keyPassword=secret$/m
end

test "leaves an already-clean passphrase alone" do
content = SetupWizard.keystore_properties_content("secret")

assert content =~ ~r/^storePassword=secret$/m
assert content =~ ~r/^keyPassword=secret$/m
assert content =~ ~r/^storeFile=upload_jks\.keystore$/m
assert content =~ ~r/^keyAlias=upload$/m
end
end
end
Loading