fix(web-app-bluesky): reset a stored app-password the rotated key cannot decrypt

The login-broker keeps each user's PDS app-password in BROKER_STORE_PATH, encrypted with BLUESKY_BRIDGE_ENCRYPTION_KEY (aadc96561b). A credential rotation changes that key while the broker_data volume keeps the old ciphertext, so decrypt() threw "Unsupported state or unable to authenticate data" inside sessionFor. storedSession recovered only from the PDS answer "Invalid identifier or password", so every handoff after the rotation answered a 62-byte 500.

storedPassword() now returns null when the entry does not decrypt, and storedSession takes the existing admin-reset path by DID for it: updateAccountPassword, then the new password is stored encrypted with the current key. Account, handle and posts stay; every other PDS error still fails. The README's failure list describes the new recovery.

Found by the store-i18n lane in CI run 40979252521 (Bluesky#0): PASS 1 green, 65 values rotated, then 30 decrypt errors in the broker log and the handoff spec red in PASS 2. A new node test restarts the broker with a rotated key and expects one reset by DID, no new account and no second reset; it fails against the old storedSession, and all 22 broker tests pass with the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-30 08:42:30 +01:00
1 parent 786902cffe
commit 0a6b6f9130
3 files changed
+43 -14

No files matched your search

@@ -64,11 +64,10 @@ broker returns HTTP 401, there is no anonymous fallback by design.
createSession failure and the user sees an HTML error page on the
handoff route. Re-trying the visit after PDS recovery completes
the handoff.
- **Encrypted attribute decrypt failure:** indicates a key mismatch
between the SPI and the broker. The broker rejects the request
with HTTP 500 rather than risk silently re-provisioning a fresh
PDS account that loses the user's existing posts. Recovery: rotate
the key and clear the attribute (manual op for now, key rotation
is in the doc 013 Future Hardening section).
- **Stored app-password does not decrypt:** `BLUESKY_BRIDGE_ENCRYPTION_KEY`
changed since the entry in `BROKER_STORE_PATH` was written. The
broker resets the password of the stored DID through the PDS admin
API (`PDS_ADMIN_PASSWORD`), stores it encrypted with the current key
and completes the handoff. Account, handle and posts stay.
- **social-app upstream error:** the broker forwards the upstream's
error response.
@@ -300,11 +300,24 @@ function sessionFor(entry) {
return pdsCreateSession({ handle: entry.handle, password: decrypt(entry.appPasswordEnc) });
}
async function storedSession(kcUsername, entry) {
function storedPassword(entry) {
try {
return await sessionFor(entry);
} catch (err) {
if (!pdsMessageIncludes(err, "Invalid identifier or password")) throw err;
return decrypt(entry.appPasswordEnc);
} catch {
return null;
}
}
async function storedSession(kcUsername, entry) {
const stored = storedPassword(entry);
if (stored === null) {
console.log(`[broker] stored app-password of ${logSafe(kcUsername)} does not decrypt with the current key → reset by DID`);
} else {
try {
return await pdsCreateSession({ handle: entry.handle, password: stored });
} catch (err) {
if (!pdsMessageIncludes(err, "Invalid identifier or password")) throw err;
}
}
const password = crypto.randomBytes(18).toString("base64url");
await pdsAdminUpdatePassword(entry.did, password);
@@ -11,6 +11,8 @@ const SERVER = path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker
const DOMAIN = "example.test";
const PUBLIC_HOST = "web.bluesky.example.test";
const ADMIN_PASSWORD = "stub-admin-password";
const KEY = Buffer.alloc(32, 7);
const ROTATED_KEY = Buffer.alloc(32, 9);
function startPds() {
const accounts = new Map();
@@ -79,7 +81,7 @@ function freePort() {
});
}
async function startBroker(pdsPort, storePath) {
async function startBroker(pdsPort, storePath, key) {
const port = await freePort();
const child = spawn(process.execPath, [SERVER], {
env: {
@@ -90,7 +92,7 @@ async function startBroker(pdsPort, storePath) {
PDS_INTERNAL_URL: `http://127.0.0.1:${pdsPort}`,
PDS_HANDLE_DOMAIN: DOMAIN,
PDS_ADMIN_PASSWORD: ADMIN_PASSWORD,
BLUESKY_BRIDGE_ENCRYPTION_KEY: `base64:${Buffer.alloc(32, 7).toString("base64")}`,
BLUESKY_BRIDGE_ENCRYPTION_KEY: `base64:${key.toString("base64")}`,
BROKER_STORE_PATH: storePath,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -141,7 +143,7 @@ let broker;
test.before(async () => {
pds = await startPds();
storePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "broker-server-")), "accounts.json");
broker = await startBroker(pds.port, storePath);
broker = await startBroker(pds.port, storePath, KEY);
});
test.after(async () => {
@@ -238,7 +240,7 @@ test("concurrent first visits create one account", async () => {
test("a broker restart keeps every account", async () => {
await stopBroker(broker);
broker = await startBroker(pds.port, storePath);
broker = await startBroker(pds.port, storePath, KEY);
const before = pds.calls.createAccount;
for (const [user, email] of [["biber", "biber@example.test"], ["kevinveenbirkenbach", "kevin@example.test"]]) {
const res = await request(broker, { user, email });
@@ -264,3 +266,18 @@ test("a password the PDS no longer accepts is reset in place", async () => {
assert.equal(pds.calls.createAccount, before);
assert.ok(pds.calls.adminUpdatePassword.includes("did:plc:i8-administrator"));
});
test("a rotated encryption key resets the stored password in place and re-encrypts it", async () => {
await stopBroker(broker);
broker = await startBroker(pds.port, storePath, ROTATED_KEY);
const accounts = pds.calls.createAccount;
const resets = pds.calls.adminUpdatePassword.length;
const res = await request(broker, { user: "mapache", email: "mapache@example.test" });
assert.equal(res.status, 200);
assert.match(res.body, /BSKY_STORAGE/);
assert.equal(pds.calls.createAccount, accounts);
assert.deepEqual(pds.calls.adminUpdatePassword.slice(resets), ["did:plc:mapache"]);
const again = await request(broker, { user: "mapache", email: "mapache@example.test" });
assert.equal(again.status, 200);
assert.equal(pds.calls.adminUpdatePassword.length, resets + 1);
});