mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
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:
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);
|
||||
});
|
||||
Reference in new issue
Block a user