diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7a99d..d310c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/lib/mob_dev/google_play/setup_wizard.ex b/lib/mob_dev/google_play/setup_wizard.ex index 0abdcc2..dc860b1 100644 --- a/lib/mob_dev/google_play/setup_wizard.ex +++ b/lib/mob_dev/google_play/setup_wizard.ex @@ -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", @@ -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()) diff --git a/test/mob_dev/google_play/setup_wizard_test.exs b/test/mob_dev/google_play/setup_wizard_test.exs index d09da71..5ceb73d 100644 --- a/test/mob_dev/google_play/setup_wizard_test.exs +++ b/test/mob_dev/google_play/setup_wizard_test.exs @@ -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. @@ -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