mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
fix: encrypt stored GitHub credentials (#797)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user