M1: pairing and streaming request succeed with Steam's PIN-masked key exchange

Current Steam rejects the legacy key-escrow pairing once the PIN is typed. The
ihslib submodule now points at scaronni's 'auth' branch (upstream master + X25519/
SHA-256 key exchange, f68de5f0), pending its merge into mariotaku/ihslib. The key
negotiated at pairing is per host: app_storage keeps it as host-<clientid>.key and
the client restores it before each streaming request. tools/relaunch.sh and
tools/stop.sh make the console loop one command; the flow backs off 60 s after a
failed pairing instead of re-prompting the PC every 5 s; a crypto self-test logs at
startup.

Tested on the console: PIN accepted, stream granted, session authenticated.
This commit is contained in:
jmelovich committed 2026-10-08 17:38:27 -07:00
1 parent d3049b4bc6
commit 670fa869ac
7 files changed
+131 -4

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
[submodule "third_party/ihslib"]
path = third_party/ihslib
url = https://github.com/mariotaku/ihslib.git
url = https://github.com/scaronni/ihslib.git
branch = auth
[submodule "third_party/protobuf-c"]
path = third_party/protobuf-c
url = https://github.com/protobuf-c/protobuf-c.git
+36
View File
@@ -106,6 +106,42 @@ const Identity &identity()
return g_identity;
}
namespace
{
std::string host_key_path(std::uint64_t host_client_id)
{
char name[64];
std::snprintf(name, sizeof name, "/host-%016llx.key", static_cast<unsigned long long>(host_client_id));
return g_data_dir + name;
}
} // namespace
bool host_key_load(std::uint64_t host_client_id, std::uint8_t key[32])
{
if (g_data_dir.empty())
return false;
const int fd = open(host_key_path(host_client_id).c_str(), O_RDONLY);
if (fd < 0)
return false;
const ssize_t got = read(fd, key, 32);
close(fd);
return got == 32;
}
void host_key_save(std::uint64_t host_client_id, const std::uint8_t key[32])
{
if (g_data_dir.empty())
return;
const int fd = open(host_key_path(host_client_id).c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
log("host key could not be saved: %s", std::strerror(errno));
return;
}
write(fd, key, 32);
close(fd);
}
void random_bytes(void *destination, std::size_t length)
{
if (ps5_get_random(destination, length) != 0)
+5
View File
@@ -26,4 +26,9 @@ const char *data_dir();
const Identity &identity();
// Console entropy (sysctl kern.rng_pseudo). Not for keys that leave the console.
void random_bytes(void *destination, std::size_t length);
// Pairing negotiates a 32-byte secret per host (Steam's PIN-masked key exchange);
// streaming from that host needs the same key again. Keyed by the host's client id.
bool host_key_load(std::uint64_t host_client_id, std::uint8_t key[32]);
void host_key_save(std::uint64_t host_client_id, const std::uint8_t key[32]);
} // namespace slp
+39 -2
View File
@@ -105,6 +105,28 @@ void ihs_log(IHS_LogLevel level, const char *tag, const char *message)
slp::log("[IHS.%s %s] %s", tag, IHS_LogLevelName(level), message);
}
// ---- crypto self-test: the pairing ticket is RSA-OAEP encrypted; a silent failure
// there makes Steam reject every PIN. ihslib does not export these; the archive does.
extern "C" int IHS_CryptoRSAEncrypt(const std::uint8_t *in, std::size_t in_length, const std::uint8_t *key,
std::size_t key_length, std::uint8_t *out, std::size_t *out_length);
extern "C" bool IHS_ClientAuthorizationPubKey(IHS_Client *client, IHS_SteamUniverse universe, std::uint8_t *key,
std::size_t *key_length);
extern "C" std::uint32_t IHS_CryptoRandomUInt32();
void crypto_self_test(IHS_Client *client)
{
std::uint8_t key[384];
std::size_t key_length = sizeof key;
const bool have_key = IHS_ClientAuthorizationPubKey(client, IHS_SteamUniversePublic, key, &key_length);
std::uint8_t out[2048];
std::size_t out_length = sizeof out;
const std::uint8_t plain[] = "self-test";
const int result = IHS_CryptoRSAEncrypt(plain, sizeof plain, key, key_length, out, &out_length);
slp::log("crypto self-test: pubkey %s (%zu bytes), rsa encrypt result %d (-0x%x), %zu bytes out, random %u",
have_key ? "ok" : "MISSING", key_length, result, static_cast<unsigned>(-result), out_length,
IHS_CryptoRandomUInt32());
}
// ---- client phase: discovery, authorization, streaming request -------------
struct ClientFlow
@@ -116,6 +138,7 @@ struct ClientFlow
bool have_session = false;
IHS_SessionInfo session{};
char pin[8] = {};
std::uint8_t host_key[32] = {};
};
void request_stream(IHS_Client *client, ClientFlow &flow)
@@ -151,6 +174,12 @@ void on_discovered(IHS_Client *client, const IHS_HostInfo *host, void *context)
status.host = std::string{host->hostname} + " " + address;
}
free(address);
// Already paired with this PC: the stream request must use the key negotiated then.
if (slp::host_key_load(host->clientId, flow.host_key))
{
IHS_ClientSetSecretKey(client, flow.host_key);
slp::log("using the stored key for %s", host->hostname);
}
request_stream(client, flow);
}
@@ -163,6 +192,9 @@ void on_authorization_success(IHS_Client *client, const IHS_HostInfo *, std::uin
{
auto &flow = *static_cast<ClientFlow *>(context);
slp::log("authorized, steam id %llu", static_cast<unsigned long long>(steam_id));
// The client now holds the key negotiated with this host; keep it for next time.
IHS_ClientGetSecretKey(client, flow.host_key);
slp::host_key_save(flow.host.clientId, flow.host_key);
{
std::lock_guard lock{status.mutex};
status.pin.clear();
@@ -307,9 +339,11 @@ void *flow_thread(void *)
{
flow.host_chosen = false;
flow.have_session = false;
flow.authorization_tried = false;
set_state("LOOKING FOR A PC RUNNING STEAM");
IHS_Client *client = IHS_ClientCreate(&flow.config);
IHS_ClientSetLogFunction(client, ihs_log);
crypto_self_test(client);
IHS_ClientDiscoveryCallbacks discovery{};
discovery.discovered = on_discovered;
IHS_ClientAuthorizationCallbacks authorization{};
@@ -330,7 +364,9 @@ void *flow_thread(void *)
if (flow.have_session)
{
IHS_Session *session = IHS_SessionCreate(&flow.config, &flow.session);
IHS_ClientConfig session_config = flow.config;
session_config.secretKey = flow.host_key;
IHS_Session *session = IHS_SessionCreate(&session_config, &flow.session);
IHS_SessionSetLogFunction(session, ihs_log);
IHS_StreamSessionCallbacks session_callbacks{};
session_callbacks.configuring = on_session_configuring;
@@ -357,7 +393,8 @@ void *flow_thread(void *)
slp::log("IHS_SessionConnect failed");
IHS_SessionDestroy(session);
}
sleep(5);
// A failed pairing re-prompts the PC on every retry: back off properly.
sleep(flow.authorization_tried && !flow.have_session ? 60 : 5);
}
return nullptr;
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# steamlink-ps5 - The edit-deploy-test loop in one command.
# SPDX-License-Identifier: GPL-3.0-or-later
#
# PS5_HOST=192.168.x.x tools/relaunch.sh [--no-build]
#
# Stops the title if it is running (a running title's files cannot be replaced),
# uploads dist/<TITLE_ID> over FTP, launches the title through shsrv and prints the
# first lines of its log. Needs ftpsrv (2121) and shsrv (2323) on the console.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
host=${PS5_HOST:?set PS5_HOST to the console IP address}
title_id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["titleId"])' "$root/sce_sys/param.json")
sh() { timeout 30 python3 "$root/tools/ps5sh.py" "$host" "$@"; }
[[ ${1:-} == --no-build ]] || make -C "$root" --no-print-directory app
pid=$(sh ps 2>/dev/null | awk -v t="$title_id" '$8 == t {print $1}' || true)
if [[ -n $pid ]]; then
echo "==> [relaunch] stopping $title_id (pid $pid)"
sh "kill $pid" >/dev/null 2>&1 || true
sleep 4
fi
PS5_HOST=$host bash "$root/tools/deploy.sh" | tail -1
echo "==> [relaunch] launching $title_id"
sh "launch $title_id" >/dev/null 2>&1 || true
sleep "${RELAUNCH_WAIT:-12}"
echo "==> [relaunch] log:"
curl -s --max-time 10 "ftp://$host:2121/data/steamlink-ps5/log.txt" | tail -n "${RELAUNCH_LINES:-25}"
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# steamlink-ps5 - Stop the title on the console (shsrv), if it is running.
# PS5_HOST=192.168.x.x tools/stop.sh
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
host=${PS5_HOST:?set PS5_HOST to the console IP address}
title_id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["titleId"])' "$root/sce_sys/param.json")
pid=$(timeout 30 python3 "$root/tools/ps5sh.py" "$host" ps 2>/dev/null | awk -v t="$title_id" '$8 == t {print $1}' || true)
if [[ -z $pid ]]; then
echo "==> [stop] $title_id is not running"
exit 0
fi
echo "==> [stop] killing $title_id (pid $pid)"
timeout 30 python3 "$root/tools/ps5sh.py" "$host" "kill $pid" >/dev/null 2>&1 || true
sleep 3
if timeout 30 python3 "$root/tools/ps5sh.py" "$host" ps 2>/dev/null | awk -v t="$title_id" '$8 == t' | grep -q .; then
echo "==> [stop] still running" >&2
exit 1
fi
echo "==> [stop] stopped"