diff --git a/scripts/release-android.ps1 b/scripts/release-android.ps1 index 77b366e..64a7b88 100644 --- a/scripts/release-android.ps1 +++ b/scripts/release-android.ps1 @@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again. if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging } New-Item -ItemType Directory -Path $Staging | Out-Null - # No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a - # 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at. - & dotnet build $Project --no-restore --configuration Release ` - -t:SignAndroidPackage ` - -p:DodoChannel=release ` - -p:AndroidKeyStore=true ` - -p:AndroidSigningKeyStore=$KeystorePath ` - -p:AndroidSigningKeyAlias=$KeyAlias ` - -p:AndroidSigningStorePass=$storePlain ` - -p:AndroidSigningKeyPass=$keyPlain + # ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE. + # + # They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is + # not. A process's command line is not a secret on Windows: any process running as this user can read + # every argument of every other with one call — + # + # Get-CimInstance Win32_Process | Select-Object Name, CommandLine + # + # — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The + # comment above says the passwords are never stored, and with the arguments written that way it was + # true of the disk and false of everything watching the machine. + # + # env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying + # the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of + # the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not + # supported and file: is the documented alternative; this channel ships an APK, and the guard below + # fails loudly rather than silently signing with the literal string if that ever changes. + # + # Not a perfect channel — an environment block is readable by a process with rights over this one, the + # same as memory is — but it is off the command line, out of the audit trail and out of any log that + # echoes the build invocation, which is the whole of the difference being bought here. + # + # -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment. + # The finally clears them from this process either way, and a persistent node would have kept its own + # inherited copy for the next quarter of an hour regardless of what happens here. + $packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo ` + -p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + if ($packageFormat -eq 'aab') { + Stop-With @' +This channel now builds an aab, and the env: prefix this script signs with is not supported for that +format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself. + +Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes +that file removes it again. +'@ + } + + try { + $env:DODOSSH_ANDROID_STOREPASS = $storePlain + $env:DODOSSH_ANDROID_KEYPASS = $keyPlain + + # No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a + # 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at. + & dotnet build $Project --no-restore --configuration Release ` + -nodeReuse:false ` + -t:SignAndroidPackage ` + -p:DodoChannel=release ` + -p:AndroidKeyStore=true ` + -p:AndroidSigningKeyStore=$KeystorePath ` + -p:AndroidSigningKeyAlias=$KeyAlias ` + -p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS ` + -p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS + } + finally { + # SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there — + # and this runs on the failure path, where it may well not be. + [Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null) + [Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null) + } if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' } diff --git a/scripts/release-macos.sh b/scripts/release-macos.sh index b7964c3..661b78d 100644 --- a/scripts/release-macos.sh +++ b/scripts/release-macos.sh @@ -38,9 +38,11 @@ # xcrun notarytool store-credentials DodoSSH \ # --apple-id you@example.com --team-id TEAMID --password # -# The forge token is the one real secret, and it is prompted for rather than read from a file or the -# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer -# minutes a credential that can publish an update spends in a shell's memory the better. +# The forge token is the one real secret, and it is prompted for rather than read from a file or an +# ambient environment variable, and only in the phase that needs it — for the reason the Windows script +# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better. +# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the +# alternative is --token on a command line that is not private. See the comment at the upload itself. # # Usage: # bash scripts/release-macos.sh @@ -195,10 +197,18 @@ if [ "$UPLOAD" -eq 1 ]; then # also already hold the Windows package for the same tag, which is the case --merge is really doing # the work for: without it the second platform to publish a given version fails on a release that # exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart. + # + # No --token in this array, and that is deliberate. It used to be there, and an argument is not a + # private place to put a credential: /proc//cmdline is world-readable on Linux, `ps` hands out + # other processes' arguments freely, and whatever agent is auditing process creation on this machine + # keeps its own copy. The comment above is about shell history and was never the whole story. + # + # vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument. + # Exported for the one call and unset straight after; an environment block is still readable by a + # process with rights over this one, but it is out of every process listing and audit log. UPLOAD_ARGS=( upload gitea --repoUrl "$REPO_URL" - --token "$TOKEN" --outputDir "$RELEASES_DIR" --channel "$CHANNEL" --releaseName "$TAG" @@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then esac write_step 'Uploading' - dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.' + + # Unset on the way out whether or not the upload worked, so a failed release does not leave the token + # sitting in this shell for whatever runs next. + export VPK_TOKEN="$TOKEN" + vpk_status=0 + dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$? + unset VPK_TOKEN + + [ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.' write_step "Published $TAG." exit 0 diff --git a/scripts/release-windows.ps1 b/scripts/release-windows.ps1 index 377a668..a4ceceb 100644 --- a/scripts/release-windows.ps1 +++ b/scripts/release-windows.ps1 @@ -16,9 +16,11 @@ 2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild, so the bytes that reach users are the bytes that were installed and checked. - The token is prompted for rather than read from a file or an environment variable, and only in the phase - that needs it — the build does not, and the fewer minutes a credential that can publish an update spends - in a shell's memory the better. + The token is prompted for rather than read from a file or an ambient environment variable, and only in + the phase that needs it — the build does not, and the fewer minutes a credential that can publish an + update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call + and cleared afterwards, because the alternative is --token on a command line every process on this + machine can read. See the comment at the upload itself. .PARAMETER Upload Publish the packages already in Releases/ instead of building. @@ -152,10 +154,24 @@ try { # --merge because Gitea already has a release entry for the pushed tag, and without it the upload # fails on a release that exists. --pre mirrors the rule the docker image job already applies to the # same tag, so a release candidate is a prerelease in both channels or in neither. + # + # ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE. + # + # The comment above says the token never lands in shell history, which was true and was not the + # whole story: it went straight into vpk's command line, and any process running as this user can + # read that off a running process with one call — + # + # Get-CimInstance Win32_Process | Select-Object Name, CommandLine + # + # — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that + # can publish an update every installed copy will fetch and run deserves better than that. + # + # vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the + # value never becomes an argument at all. An environment block is still readable by a process with + # rights over this one; what it is not is a string sitting in a process listing and an audit log. $uploadArgs = @( 'upload', 'gitea', '--repoUrl', $RepoUrl, - '--token', $token, '--outputDir', $ReleasesDir, '--channel', $Channel, '--releaseName', $tag, @@ -169,7 +185,17 @@ try { } Write-Step 'Uploading' - & dotnet vpk @uploadArgs + + try { + $env:VPK_TOKEN = $token + & dotnet vpk @uploadArgs + } + finally { + # SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there — + # and this runs on the failure path, where it may well not be. + [Environment]::SetEnvironmentVariable('VPK_TOKEN', $null) + } + if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' } Write-Step "Published $tag."