Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "4.11.3",
"@material-ui/lab": "^4.0.0-alpha.61",
"@primer/octicons-react": "^19.19.0",
"@seald-io/nedb": "^4.1.2",
"axios": "^1.12.2",
Expand All @@ -90,6 +91,7 @@
"concurrently": "^9.2.1",
"connect-mongo": "^5.1.0",
"cors": "^2.8.5",
"dayjs": "^1.11.13",
"diff2html": "^3.4.52",
"env-paths": "^3.0.0",
"escape-string-regexp": "^5.0.0",
Expand Down Expand Up @@ -119,6 +121,7 @@
"react-router-dom": "6.30.1",
"simple-git": "^3.28.0",
"ssh2": "^1.16.0",
"sshpk": "^1.18.0",
"uuid": "^11.1.0",
"validator": "^13.15.15",
"yargs": "^17.7.2"
Expand Down
48 changes: 40 additions & 8 deletions src/cli/ssh-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { utils } from 'ssh2';
import * as crypto from 'crypto';

const API_BASE_URL = process.env.GIT_PROXY_API_URL || 'http://localhost:3000';
const GIT_PROXY_COOKIE_FILE = path.join(
Expand All @@ -23,6 +25,23 @@ interface ErrorWithResponse {
message: string;
}

// Calculate SHA-256 fingerprint from SSH public key
// Note: This function is duplicated in src/service/routes/users.js to keep CLI and server independent
function calculateFingerprint(publicKeyStr: string): string | null {
try {
const parsed = utils.parseKey(publicKeyStr);
if (!parsed || parsed instanceof Error) {
return null;
}
const pubKey = parsed.getPublicSSH();
const hash = crypto.createHash('sha256').update(pubKey).digest('base64');
return `SHA256:${hash}`;
} catch (err) {
console.error('Error calculating fingerprint:', err);
return null;
}
}

async function addSSHKey(username: string, keyPath: string): Promise<void> {
try {
// Check for authentication
Expand Down Expand Up @@ -83,15 +102,28 @@ async function removeSSHKey(username: string, keyPath: string): Promise<void> {
// Read the public key file
const publicKey = fs.readFileSync(keyPath, 'utf8').trim();

// Make the API request
await axios.delete(`${API_BASE_URL}/api/v1/user/${username}/ssh-keys`, {
data: { publicKey },
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Cookie: cookies,
// Strip the comment from the key (everything after the last space)
const keyWithoutComment = publicKey.split(' ').slice(0, 2).join(' ');

// Calculate fingerprint
const fingerprint = calculateFingerprint(keyWithoutComment);
if (!fingerprint) {
console.error('Invalid SSH key format. Unable to calculate fingerprint.');
process.exit(1);
}

console.log(`Removing SSH key with fingerprint: ${fingerprint}`);

// Make the API request using fingerprint in path
await axios.delete(
`${API_BASE_URL}/api/v1/user/${username}/ssh-keys/${encodeURIComponent(fingerprint)}`,
{
withCredentials: true,
headers: {
Cookie: cookies,
},
},
});
);

console.log('SSH key removed successfully!');
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/db/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export const {
updateUser,
addPublicKey,
removePublicKey,
getPublicKeys,
} = users;
41 changes: 29 additions & 12 deletions src/db/file/users.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'fs';
import Datastore from '@seald-io/nedb';

import { User, UserQuery } from '../types';
import { User, UserQuery, PublicKeyRecord } from '../types';

const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day

Expand Down Expand Up @@ -180,7 +180,7 @@ export const getUsers = (query: Partial<UserQuery> = {}): Promise<User[]> => {
});
};

export const addPublicKey = (username: string, publicKey: string): Promise<void> => {
export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise<void> => {
return new Promise((resolve, reject) => {
findUser(username)
.then((user) => {
Expand All @@ -191,20 +191,28 @@ export const addPublicKey = (username: string, publicKey: string): Promise<void>
if (!user.publicKeys) {
user.publicKeys = [];
}
if (!user.publicKeys.includes(publicKey)) {
user.publicKeys.push(publicKey);
updateUser(user)
.then(() => resolve())
.catch(reject);
} else {
resolve();

// Check if key already exists (by key content or fingerprint)
const keyExists = user.publicKeys.some(
(k) =>
k.key === publicKey.key || (k.fingerprint && k.fingerprint === publicKey.fingerprint),
);

if (keyExists) {
reject(new Error('SSH key already exists'));
return;
}

user.publicKeys.push(publicKey);
updateUser(user)
.then(() => resolve())
.catch(reject);
})
.catch(reject);
});
};

export const removePublicKey = (username: string, publicKey: string): Promise<void> => {
export const removePublicKey = (username: string, fingerprint: string): Promise<void> => {
return new Promise((resolve, reject) => {
findUser(username)
.then((user) => {
Expand All @@ -217,7 +225,7 @@ export const removePublicKey = (username: string, publicKey: string): Promise<vo
resolve();
return;
}
user.publicKeys = user.publicKeys.filter((key) => key !== publicKey);
user.publicKeys = user.publicKeys.filter((k) => k.fingerprint !== fingerprint);
updateUser(user)
.then(() => resolve())
.catch(reject);
Expand All @@ -228,7 +236,7 @@ export const removePublicKey = (username: string, publicKey: string): Promise<vo

export const findUserBySSHKey = (sshKey: string): Promise<User | null> => {
return new Promise<User | null>((resolve, reject) => {
db.findOne({ publicKeys: sshKey }, (err: Error | null, doc: User) => {
db.findOne({ 'publicKeys.key': sshKey }, (err: Error | null, doc: User) => {
// ignore for code coverage as neDB rarely returns errors even for an invalid query
/* istanbul ignore if */
if (err) {
Expand All @@ -243,3 +251,12 @@ export const findUserBySSHKey = (sshKey: string): Promise<User | null> => {
});
});
};

export const getPublicKeys = (username: string): Promise<PublicKeyRecord[]> => {
return findUser(username).then((user) => {
if (!user) {
throw new Error('User not found');
}
return user.publicKeys || [];
});
};
10 changes: 6 additions & 4 deletions src/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AuthorisedRepo } from '../config/generated/config';
import { PushQuery, Repo, RepoQuery, Sink, User, UserQuery } from './types';
import { PushQuery, Repo, RepoQuery, Sink, User, UserQuery, PublicKeyRecord } from './types';
import * as bcrypt from 'bcryptjs';
import * as config from '../config';
import * as mongo from './mongo';
Expand Down Expand Up @@ -172,8 +172,10 @@ export const findUserBySSHKey = (sshKey: string): Promise<User | null> =>
export const getUsers = (query?: Partial<UserQuery>): Promise<User[]> => sink.getUsers(query);
export const deleteUser = (username: string): Promise<void> => sink.deleteUser(username);
export const updateUser = (user: Partial<User>): Promise<void> => sink.updateUser(user);
export const addPublicKey = (username: string, publicKey: string): Promise<void> =>
export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise<void> =>
sink.addPublicKey(username, publicKey);
export const removePublicKey = (username: string, publicKey: string): Promise<void> =>
sink.removePublicKey(username, publicKey);
export const removePublicKey = (username: string, fingerprint: string): Promise<void> =>
sink.removePublicKey(username, fingerprint);
export const getPublicKeys = (username: string): Promise<PublicKeyRecord[]> =>
sink.getPublicKeys(username);
export type { PushQuery, Repo, Sink, User } from './types';
1 change: 1 addition & 0 deletions src/db/mongo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export const {
updateUser,
addPublicKey,
removePublicKey,
getPublicKeys,
} = users;
35 changes: 29 additions & 6 deletions src/db/mongo/users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OptionalId, Document, ObjectId } from 'mongodb';
import { toClass } from '../helper';
import { User } from '../types';
import { User, PublicKeyRecord } from '../types';
import { connect } from './helper';
import _ from 'lodash';
const collectionName = 'users';
Expand Down Expand Up @@ -70,24 +70,47 @@ export const updateUser = async (user: Partial<User>): Promise<void> => {
await collection.updateOne(filter, { $set: userWithoutId }, options);
};

export const addPublicKey = async (username: string, publicKey: string): Promise<void> => {
export const addPublicKey = async (username: string, publicKey: PublicKeyRecord): Promise<void> => {
const collection = await connect(collectionName);

const user = await collection.findOne({ username: username.toLowerCase() });
if (!user) {
throw new Error('User not found');
}

const keyExists = user.publicKeys?.some(
(k: PublicKeyRecord) =>
k.key === publicKey.key || (k.fingerprint && k.fingerprint === publicKey.fingerprint),
);

if (keyExists) {
throw new Error('SSH key already exists');
}

await collection.updateOne(
{ username: username.toLowerCase() },
{ $addToSet: { publicKeys: publicKey } },
{ $push: { publicKeys: publicKey } },
);
};

export const removePublicKey = async (username: string, publicKey: string): Promise<void> => {
export const removePublicKey = async (username: string, fingerprint: string): Promise<void> => {
const collection = await connect(collectionName);
await collection.updateOne(
{ username: username.toLowerCase() },
{ $pull: { publicKeys: publicKey } },
{ $pull: { publicKeys: { fingerprint: fingerprint } } },
);
};

export const findUserBySSHKey = async function (sshKey: string): Promise<User | null> {
const collection = await connect(collectionName);
const doc = await collection.findOne({ publicKeys: { $eq: sshKey } });
const doc = await collection.findOne({ 'publicKeys.key': { $eq: sshKey } });
return doc ? toClass(doc, User.prototype) : null;
};

export const getPublicKeys = async (username: string): Promise<PublicKeyRecord[]> => {
const user = await findUser(username);
if (!user) {
throw new Error('User not found');
}
return user.publicKeys || [];
};
16 changes: 12 additions & 4 deletions src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export type QueryValue = string | boolean | number | undefined;

export type UserRole = 'canPush' | 'canAuthorise';

export type PublicKeyRecord = {
key: string;
name: string;
addedAt: string;
fingerprint: string;
};

export class Repo {
project: string;
name: string;
Expand Down Expand Up @@ -58,7 +65,7 @@ export class User {
email: string;
admin: boolean;
oidcId?: string | null;
publicKeys?: string[];
publicKeys?: PublicKeyRecord[];
displayName?: string | null;
title?: string | null;
_id?: string;
Expand All @@ -70,7 +77,7 @@ export class User {
email: string,
admin: boolean,
oidcId: string | null = null,
publicKeys: string[] = [],
publicKeys: PublicKeyRecord[] = [],
_id?: string,
) {
this.username = username;
Expand Down Expand Up @@ -111,6 +118,7 @@ export interface Sink {
createUser: (user: User) => Promise<void>;
deleteUser: (username: string) => Promise<void>;
updateUser: (user: Partial<User>) => Promise<void>;
addPublicKey: (username: string, publicKey: string) => Promise<void>;
removePublicKey: (username: string, publicKey: string) => Promise<void>;
addPublicKey: (username: string, publicKey: PublicKeyRecord) => Promise<void>;
removePublicKey: (username: string, fingerprint: string) => Promise<void>;
getPublicKeys: (username: string) => Promise<PublicKeyRecord[]>;
}
4 changes: 4 additions & 0 deletions src/service/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ router.get('/uiRouteAuth', (_req: Request, res: Response) => {
res.send(config.getUIRouteAuth());
});

router.get('/ssh', (_req: Request, res: Response) => {
res.send(config.getSSHConfig());
});

export default router;
Loading
Loading