fix: encrypt stored GitHub credentials (#797)

This commit is contained in:
Thomas Durieux
2026-09-08 13:04:28 +02:00
committed by GitHub
parent b070d7e281
commit 3fb376dd53
32 changed files with 1098 additions and 188 deletions
+47
View File
@@ -0,0 +1,47 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const { createTokenCipher } = require("../src/core/credential-crypto");
const { redactSecrets } = require("../src/core/redact-secrets");
const keys = JSON.stringify({ old: Buffer.alloc(32, 1).toString("base64"), next: Buffer.alloc(32, 2).toString("base64") });
describe("credential encryption", () => {
const cipher = createTokenCipher(keys, "old");
it("round trips with independent nonces and no plaintext in the envelope", () => {
const a = cipher.encrypt("secret-token", "owner", "github");
const b = cipher.encrypt("secret-token", "owner", "github");
expect(a.nonce).not.to.equal(b.nonce);
expect(JSON.stringify(a)).not.to.include("secret-token");
expect(cipher.decrypt(a, "owner", "github")).to.equal("secret-token");
});
it("rejects altered envelopes and a different owner/provider", () => {
const a = cipher.encrypt("secret-token", "owner", "github");
for (const field of ["nonce", "tag", "ciphertext"]) {
const bytes = Buffer.from(a[field], "base64"); bytes[0] ^= 1;
expect(() => cipher.decrypt({ ...a, [field]: bytes.toString("base64") }, "owner", "github")).to.throw("Credential decryption failed");
}
for (const patch of [{ version: 2 }, { keyId: "missing" }, { tag: "YQ==" }, { nonce: "!!!!" }]) {
expect(() => cipher.decrypt({ ...a, ...patch }, "owner", "github")).to.throw();
}
expect(() => cipher.decrypt(a, "someone-else", "github")).to.throw();
expect(() => cipher.decrypt(a, "owner", "other")).to.throw();
const wrong = createTokenCipher(JSON.stringify({ old: Buffer.alloc(32, 3).toString("base64") }), "old");
expect(() => wrong.decrypt(a, "owner", "github")).to.throw();
});
it("supports key rotation while retaining reads of old credentials", () => {
const rotated = createTokenCipher(keys, "next");
expect(rotated.decrypt(cipher.encrypt("secret", "owner", "github"), "owner", "github")).to.equal("secret");
expect(rotated.encrypt("secret", "owner", "github").keyId).to.equal("next");
});
it("refuses missing, malformed, and short keys", () => {
for (const raw of ["", "null", "[]", "{}", '{"old":"YQ=="}']) {
expect(() => createTokenCipher(raw, "old")).to.throw();
}
});
it("redacts nested credentials, authorization and tokens in URLs/errors", () => {
const input = { accessTokens: { github: "plain" }, nested: { authorization: "Bearer plain", encryptedToken: { ciphertext: "abc" } }, message: "failed ghp_abcdef https://example.test/?token=plain" };
const result = JSON.stringify(redactSecrets(input));
expect(result).not.to.include("plain");
expect(result).not.to.include("ghp_abcdef");
expect(result).not.to.include("abc");
});
});
+214
View File
@@ -0,0 +1,214 @@
const process = require("process");
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const mongoose = require("mongoose");
const { migrateCredentials, verifyCredentials, enforceCredentialStorage } = require("../src/core/migrate-credentials");
const { createTokenCipher } = require("../src/core/credential-crypto");
// Always use a newly named disposable database, never the database in the URI.
const describeMongo = process.env.TEST_MONGODB_URI ? describe : describe.skip;
describeMongo("credential migration (MongoDB)", function () {
this.timeout(20000);
let client, db, owner;
const cipher = createTokenCipher(JSON.stringify({ test: Buffer.alloc(32, 7).toString("base64") }), "test");
const apply = { apply: true, removeLegacy: true };
before(async () => {
client = new mongoose.mongo.MongoClient(process.env.TEST_MONGODB_URI);
await client.connect();
db = client.db(`credential_test_${new mongoose.Types.ObjectId()}`);
});
beforeEach(async () => {
await db.dropDatabase();
owner = new mongoose.Types.ObjectId();
await db.collection("users").insertOne({ _id: owner, username: "test", accessTokens: { github: "secret" } });
});
after(async () => { if (db) await db.dropDatabase(); if (client) await client.close(); });
it("dry run does not modify data", async () => {
const result = await migrateCredentials(db, cipher);
expect(result.created).to.equal(1);
expect(await db.collection("credentials").countDocuments()).to.equal(0);
expect((await db.collection("users").findOne({ _id: owner })).accessTokens.github).to.equal("secret");
});
it("encrypts once, cleans all legacy locations, and is safe to rerun", async () => {
for (const name of ["anonymizedrepositories", "anonymizedgists", "anonymizedpullrequests"]) {
await db.collection(name).insertOne({ owner, source: { accessToken: "secret" }, accessToken: "secret" });
}
const first = await migrateCredentials(db, cipher, apply);
expect(first.issues).to.equal(0);
const credential = await db.collection("credentials").findOne({ ownerId: owner });
expect(JSON.stringify(credential)).not.to.include("secret");
expect(cipher.decrypt(credential.encryptedToken, String(owner), "github")).to.equal("secret");
expect(await verifyCredentials(db, cipher)).to.deep.equal({ checked: 1, legacy: 0 });
expect((await migrateCredentials(db, cipher, apply)).created).to.equal(0);
expect((await db.collection("credentials").findOne({ ownerId: owner })).encryptedToken).to.deep.equal(credential.encryptedToken);
});
it("reports resource conflicts without deleting either token", async () => {
await db.collection("anonymizedgists").insertOne({ owner, source: { accessToken: "different" } });
const events = [];
const result = await migrateCredentials(db, cipher, { ...apply, report: e => events.push(e) });
expect(result.issues).to.equal(1);
expect(JSON.stringify(events)).not.to.include("different");
expect(await db.collection("credentials").countDocuments()).to.equal(0);
expect((await db.collection("users").findOne({ _id: owner })).accessTokens.github).to.equal("secret");
expect((await migrateCredentials(db, cipher, { ...apply, preferOwnerToken: true })).issues).to.equal(0);
expect(await verifyCredentials(db, cipher)).to.deep.equal({ checked: 1, legacy: 0 });
});
it("preserves a newer credential and requires explicit conflict resolution", async () => {
const envelope = cipher.encrypt("new-login", String(owner), "github");
await db.collection("credentials").insertOne({ ownerId: owner, provider: "github", encryptedToken: envelope, updatedAt: new Date() });
expect((await migrateCredentials(db, cipher, apply)).issues).to.equal(1);
await migrateCredentials(db, cipher, { ...apply, preferOwnerToken: true });
expect((await db.collection("credentials").findOne({ ownerId: owner })).encryptedToken).to.deep.equal(envelope);
});
it("migrates resource-only credentials but never arbitrarily picks between them", async () => {
await db.collection("users").updateOne({ _id: owner }, { $unset: { accessTokens: "" } });
await db.collection("anonymizedgists").insertOne({ owner, source: { accessToken: "resource" } });
await db.collection("anonymizedpullrequests").insertOne({ owner, source: { accessToken: "other" } });
expect((await migrateCredentials(db, cipher, { ...apply, preferOwnerToken: true })).issues).to.equal(1);
await db.collection("anonymizedpullrequests").deleteMany({});
expect((await migrateCredentials(db, cipher, apply)).issues).to.equal(0);
const row = await db.collection("credentials").findOne({ ownerId: owner });
expect(cipher.decrypt(row.encryptedToken, String(owner), "github")).to.equal("resource");
});
it("reports missing owners and malformed tokens", async () => {
await db.collection("anonymizedrepositories").insertOne({ source: { accessToken: "orphan" } });
await db.collection("users").updateOne({ _id: owner }, { $set: { "accessTokens.github": { invalid: true } } });
expect((await migrateCredentials(db, cipher, apply)).issues).to.equal(2);
expect(await db.collection("credentials").countDocuments()).to.equal(0);
});
it("does not recreate credentials for removed accounts", async () => {
await db.collection("users").updateOne({ _id: owner }, { $set: { status: "removed" } });
expect((await migrateCredentials(db, cipher, apply)).issues).to.equal(0);
expect(await verifyCredentials(db, cipher)).to.deep.equal({ checked: 0, legacy: 0 });
});
it("resumes after interruption between backfill and cleanup", async () => {
await migrateCredentials(db, cipher, { apply: true });
const envelope = (await db.collection("credentials").findOne({ ownerId: owner })).encryptedToken;
expect((await verifyCredentials(db, cipher)).legacy).to.equal(1);
await migrateCredentials(db, cipher, apply);
expect(await verifyCredentials(db, cipher)).to.deep.equal({ checked: 1, legacy: 0 });
expect((await db.collection("credentials").findOne({ ownerId: owner })).encryptedToken).to.deep.equal(envelope);
});
it("refuses cleanup when ciphertext cannot be authenticated", async () => {
await migrateCredentials(db, cipher, { apply: true });
await db.collection("credentials").updateOne({ ownerId: owner }, { $set: { "encryptedToken.tag": Buffer.alloc(16).toString("base64") } });
expect((await migrateCredentials(db, cipher, apply)).issues).to.equal(1);
expect((await db.collection("users").findOne({ _id: owner })).accessTokens.github).to.equal("secret");
});
it("enforces the unique owner/provider index", async () => {
await migrateCredentials(db, cipher, apply);
const row = await db.collection("credentials").findOne({ ownerId: owner });
delete row._id;
try { await db.collection("credentials").insertOne(row); throw new Error("expected duplicate"); }
catch (error) { expect(error.code).to.equal(11000); }
});
it("enforces plaintext rejection in MongoDB after verification", async () => {
await migrateCredentials(db, cipher, apply);
await enforceCredentialStorage(db, cipher);
try { await db.collection("users").updateOne({ _id: owner }, { $set: { "accessTokens.github": "bad" } }); throw new Error("expected rejection"); }
catch (error) { expect(error.code).to.equal(121); }
try { await db.collection("anonymizedgists").insertOne({ owner, source: { accessToken: "bad" } }); throw new Error("expected rejection"); }
catch (error) { expect(error.code).to.equal(121); }
});
});
describeMongo("credential access (MongoDB)", function () {
this.timeout(20000);
const config = require("../src/config").default;
const Credential = require("../src/core/model/credentials/credentials.model").default;
const UserModel = require("../src/core/model/users/users.model").default;
const RepoModel = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const GistModel = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
const PullModel = require("../src/core/model/anonymizedPullRequests/anonymizedPullRequests.model").default;
const { setCredential, getCredentialToken, replaceCredential } = require("../src/core/credentials");
let settings, owner;
before(async () => {
settings = [config.CREDENTIAL_KEYS, config.CREDENTIAL_ACTIVE_KEY_ID, config.CREDENTIAL_LEGACY_READS];
config.CREDENTIAL_KEYS = JSON.stringify({ test: Buffer.alloc(32, 9).toString("base64") });
config.CREDENTIAL_ACTIVE_KEY_ID = "test";
config.CREDENTIAL_LEGACY_READS = false;
await mongoose.connect(process.env.TEST_MONGODB_URI, { dbName: `credential_access_test_${new mongoose.Types.ObjectId()}` });
await Credential.init();
});
beforeEach(async () => {
await Credential.deleteMany({});
owner = await UserModel.create({ username: `owner-${new mongoose.Types.ObjectId()}` });
});
after(async () => {
await mongoose.connection.dropDatabase();
await mongoose.disconnect();
[config.CREDENTIAL_KEYS, config.CREDENTIAL_ACTIVE_KEY_ID, config.CREDENTIAL_LEGACY_READS] = settings;
});
it("persists only encrypted credentials and hides envelopes in ordinary queries", async () => {
await setCredential(owner.id, "real-secret");
const raw = await Credential.collection.findOne({ ownerId: owner._id });
expect(raw.ownerId.equals(owner._id)).to.equal(true);
expect(JSON.stringify(raw)).not.to.include("real-secret");
expect((await Credential.findOne({ ownerId: owner._id })).encryptedToken).to.equal(undefined);
expect(await getCredentialToken(owner.id)).to.equal("real-secret");
expect((await UserModel.collection.findOne({ _id: owner._id })).accessTokens).to.equal(undefined);
});
it("handles simultaneous logins with one owner/provider row", async () => {
await Promise.all(Array.from({ length: 8 }, (_, i) => setCredential(owner.id, `token-${i}`)));
expect(await Credential.countDocuments({ ownerId: owner._id })).to.equal(1);
expect(await getCredentialToken(owner.id)).to.match(/^token-\d$/);
});
it("updates the encrypted envelope atomically on refresh", async () => {
await setCredential(owner.id, "previous");
expect(await replaceCredential(owner.id, "previous", "next")).to.equal(true);
expect(await replaceCredential(owner.id, "previous", "stale")).to.equal(false);
expect(await getCredentialToken(owner.id)).to.equal("next");
});
it("resolves gists and pull requests by owner and observes token changes", async () => {
const Gist = require("../src/core/Gist").default;
const PullRequest = require("../src/core/PullRequest").default;
const gist = new Gist(await GistModel.create({ owner: owner._id, source: { gistId: "test" } }));
const pull = new PullRequest(await PullModel.create({ owner: owner._id, source: { pullRequestId: "1" } }));
await setCredential(owner.id, "first");
expect(await gist.getToken()).to.equal("first");
expect(await pull.getToken()).to.equal("first");
await setCredential(owner.id, "second");
expect(await gist.getToken()).to.equal("second");
expect(await pull.getToken()).to.equal("second");
expect((await GistModel.collection.findOne({ _id: gist.model._id })).source.accessToken).to.equal(undefined);
expect((await PullModel.collection.findOne({ _id: pull.model._id })).source.accessToken).to.equal(undefined);
});
it("refreshes a repository credential without copying it onto the repository", async () => {
const Repository = require("../src/core/Repository").default;
const repo = new Repository(await RepoModel.create({ owner: owner._id, repoId: "test-repo", source: { type: "GitHubStream" } }));
await setCredential(owner.id, "old-token");
await Credential.updateOne({ ownerId: owner._id }, { $set: { updatedAt: new Date(0) } });
const originalFetch = global.fetch;
global.fetch = async (url, options) => {
expect(url).to.include("api.github.com/applications/");
expect(JSON.parse(options.body).access_token).to.equal("old-token");
return { ok: true, json: async () => ({ token: "refreshed-token" }) };
};
try { expect(await repo.getToken()).to.equal("refreshed-token"); }
finally { global.fetch = originalFetch; }
expect(await getCredentialToken(owner.id)).to.equal("refreshed-token");
expect((await RepoModel.collection.findOne({ _id: repo.model._id })).source.accessToken).to.equal(undefined);
});
it("OAuth login writes a credential and returns a token-free session user", async () => {
const passport = require("passport");
require("../src/server/routes/connection");
const result = await new Promise((resolve, reject) => passport._strategy("github")._verify("oauth-secret", "refresh-secret", {
id: "external-test", username: owner.username, emails: [], photos: [],
}, (error, user) => error ? reject(error) : resolve(user)));
expect(JSON.stringify(result)).not.to.include("oauth-secret");
expect(JSON.stringify(result)).not.to.include("refresh-secret");
expect(await getCredentialToken(owner.id)).to.equal("oauth-secret");
expect((await UserModel.collection.findOne({ _id: owner._id })).accessTokens).to.equal(undefined);
});
it("reads hidden legacy resource tokens only during compatibility mode", async () => {
const resource = await GistModel.collection.insertOne({ gistId: "legacy-gist", owner: owner._id, source: { accessToken: "legacy-resource" } });
const lookup = { collection: "anonymizedgists", id: resource.insertedId };
expect(await getCredentialToken(owner.id, "github", lookup)).to.equal("");
config.CREDENTIAL_LEGACY_READS = true;
try {
expect(await getCredentialToken(owner.id, "github", lookup)).to.equal("legacy-resource");
await setCredential(owner.id, "encrypted-wins");
expect(await getCredentialToken(owner.id, "github", lookup)).to.equal("encrypted-wins");
} finally { config.CREDENTIAL_LEGACY_READS = false; }
});
});
+21
View File
@@ -0,0 +1,21 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const passport = require("passport");
require("../src/server/routes/connection");
describe("credential-free sessions", () => {
it("serializes only the owner ID", done => {
passport.serializeUser({ user: { _id: "507f1f77bcf86cd799439011", accessTokens: { github: "secret" } }, accessToken: "secret" }, (error, value) => {
expect(error).to.equal(null);
expect(value).to.equal("507f1f77bcf86cd799439011");
done();
});
});
it("rejects the legacy session object", done => {
passport.deserializeUser({ user: { _id: "507f1f77bcf86cd799439011" }, accessToken: "secret" }, (error, value) => {
expect(error).to.equal(null);
expect(value).to.equal(false);
done();
});
});
});
+63
View File
@@ -0,0 +1,63 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const { Types } = require("mongoose");
const config = require("../src/config").default;
const Model = require("../src/core/model/credentials/credentials.model").default;
const UserModel = require("../src/core/model/users/users.model").default;
const { getCredentialToken, setCredential, replaceCredential, credentialCipher } = require("../src/core/credentials");
describe("credential storage boundary", () => {
const owner = new Types.ObjectId().toString();
let original, settings, stored;
beforeEach(() => {
original = { findOne: Model.findOne, updateOne: Model.updateOne, findById: UserModel.findById };
settings = [config.CREDENTIAL_KEYS, config.CREDENTIAL_ACTIVE_KEY_ID, config.CREDENTIAL_LEGACY_READS];
config.CREDENTIAL_KEYS = JSON.stringify({ test: Buffer.alloc(32, 4).toString("base64") });
config.CREDENTIAL_ACTIVE_KEY_ID = "test";
config.CREDENTIAL_LEGACY_READS = false;
stored = null;
Model.findOne = () => ({ select: () => ({ lean: async () => stored }) });
Model.updateOne = async (filter, update) => {
stored = { _id: new Types.ObjectId(), ownerId: owner, provider: "github", ...update.$set };
return { modifiedCount: 1 };
};
});
afterEach(() => {
Object.assign(Model, { findOne: original.findOne, updateOne: original.updateOne });
UserModel.findById = original.findById;
[config.CREDENTIAL_KEYS, config.CREDENTIAL_ACTIVE_KEY_ID, config.CREDENTIAL_LEGACY_READS] = settings;
});
it("writes only ciphertext and resolves the token by owner/provider", async () => {
await setCredential(owner, "github-secret");
expect(JSON.stringify(stored)).not.to.include("github-secret");
expect(await getCredentialToken(owner)).to.equal("github-secret");
});
it("refreshes only the credential whose token still matches", async () => {
await setCredential(owner, "new-login-token");
expect(await replaceCredential(owner, "stale-token", "refresh-token")).to.equal(false);
expect(await getCredentialToken(owner)).to.equal("new-login-token");
expect(await replaceCredential(owner, "new-login-token", "refresh-token")).to.equal(true);
expect(await getCredentialToken(owner)).to.equal("refresh-token");
});
it("fails closed on corrupt ciphertext even with legacy reads enabled", async () => {
config.CREDENTIAL_LEGACY_READS = true;
await setCredential(owner, "github-secret");
stored.encryptedToken.tag = Buffer.alloc(16).toString("base64");
UserModel.findById = () => { throw new Error("must not fall back"); };
try { await getCredentialToken(owner); throw new Error("expected failure"); }
catch (error) { expect(error.message).to.equal("Credential decryption failed"); }
});
it("reads legacy users only when explicitly enabled", async () => {
let reads = 0;
UserModel.findById = () => ({ select: async () => { reads++; return { accessTokens: { github: "legacy" } }; } });
expect(await getCredentialToken(owner)).to.equal("");
expect(reads).to.equal(0);
config.CREDENTIAL_LEGACY_READS = true;
expect(await getCredentialToken(owner)).to.equal("legacy");
});
it("uses a unique owner/provider index and hides envelopes by default", () => {
expect(Model.schema.indexes()).to.deep.include([{ ownerId: 1, provider: 1 }, { unique: true, background: true }]);
expect(Model.schema.path("encryptedToken").options.select).to.equal(false);
expect(credentialCipher()).to.have.property("encrypt");
});
});