Skip to content

Commit e5da79c

Browse files
committed
chore: add SSH key fingerprint API and UI updates
1 parent 0570c4c commit e5da79c

2 files changed

Lines changed: 100 additions & 53 deletions

File tree

src/service/routes/users.ts

Lines changed: 95 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import express, { Request, Response } from 'express';
22
import { utils } from 'ssh2';
3+
import crypto from 'crypto';
34

45
import * as db from '../../db';
56
import { toPublicUser } from './publicApi';
6-
import { DuplicateSSHKeyError, UserNotFoundError } from '../../errors/DatabaseErrors';
77

88
const router = express.Router();
9-
const parseKey = utils.parseKey;
9+
10+
// Calculate SHA-256 fingerprint from SSH public key
11+
// Note: This function is duplicated in src/cli/ssh-key.ts to keep CLI and server independent
12+
function calculateFingerprint(publicKeyStr: string): string | null {
13+
try {
14+
const parsed = utils.parseKey(publicKeyStr);
15+
if (!parsed || parsed instanceof Error) {
16+
return null;
17+
}
18+
const pubKey = parsed.getPublicSSH();
19+
const hash = crypto.createHash('sha256').update(pubKey).digest('base64');
20+
return `SHA256:${hash}`;
21+
} catch (err) {
22+
console.error('Error calculating fingerprint:', err);
23+
return null;
24+
}
25+
}
1026

1127
router.get('/', async (req: Request, res: Response) => {
1228
console.log('fetching users');
@@ -25,91 +41,126 @@ router.get('/:id', async (req: Request, res: Response) => {
2541
res.send(toPublicUser(user));
2642
});
2743

44+
// Get SSH key fingerprints for a user
45+
router.get('/:username/ssh-key-fingerprints', async (req: Request, res: Response) => {
46+
if (!req.user) {
47+
res.status(401).json({ error: 'Authentication required' });
48+
return;
49+
}
50+
51+
const { username, admin } = req.user as { username: string; admin: boolean };
52+
const targetUsername = req.params.username.toLowerCase();
53+
54+
// Only allow users to view their own keys, or admins to view any keys
55+
if (username !== targetUsername && !admin) {
56+
res.status(403).json({ error: 'Not authorized to view keys for this user' });
57+
return;
58+
}
59+
60+
try {
61+
const publicKeys = await db.getPublicKeys(targetUsername);
62+
const keyFingerprints = publicKeys.map((keyRecord) => ({
63+
fingerprint: keyRecord.fingerprint,
64+
name: keyRecord.name,
65+
addedAt: keyRecord.addedAt,
66+
}));
67+
res.json(keyFingerprints);
68+
} catch (error) {
69+
console.error('Error retrieving SSH keys:', error);
70+
res.status(500).json({ error: 'Failed to retrieve SSH keys' });
71+
}
72+
});
73+
2874
// Add SSH public key
2975
router.post('/:username/ssh-keys', async (req: Request, res: Response) => {
3076
if (!req.user) {
31-
res.status(401).json({ error: 'Login required' });
77+
res.status(401).json({ error: 'Authentication required' });
3278
return;
3379
}
3480

3581
const { username, admin } = req.user as { username: string; admin: boolean };
3682
const targetUsername = req.params.username.toLowerCase();
3783

38-
// Admins can add to any account, users can only add to their own
84+
// Only allow users to add keys to their own account, or admins to add to any account
3985
if (username !== targetUsername && !admin) {
4086
res.status(403).json({ error: 'Not authorized to add keys for this user' });
4187
return;
4288
}
4389

44-
const { publicKey } = req.body;
45-
if (!publicKey || typeof publicKey !== 'string') {
90+
const { publicKey, name } = req.body;
91+
if (!publicKey) {
4692
res.status(400).json({ error: 'Public key is required' });
4793
return;
4894
}
4995

50-
try {
51-
const parsedKey = parseKey(publicKey.trim());
96+
// Strip the comment from the key (everything after the last space)
97+
const keyWithoutComment = publicKey.trim().split(' ').slice(0, 2).join(' ');
5298

53-
if (parsedKey instanceof Error) {
54-
res.status(400).json({ error: `Invalid SSH key: ${parsedKey.message}` });
55-
return;
56-
}
99+
// Calculate fingerprint
100+
const fingerprint = calculateFingerprint(keyWithoutComment);
101+
if (!fingerprint) {
102+
res.status(400).json({ error: 'Invalid SSH public key format' });
103+
return;
104+
}
57105

58-
if (parsedKey.isPrivateKey()) {
59-
res.status(400).json({ error: 'Invalid SSH key: Must be a public key' });
60-
return;
61-
}
106+
const publicKeyRecord = {
107+
key: keyWithoutComment,
108+
name: name || 'Unnamed Key',
109+
addedAt: new Date().toISOString(),
110+
fingerprint: fingerprint,
111+
};
62112

63-
const keyWithoutComment = parsedKey.getPublicSSH().toString('utf8');
64-
console.log('Adding SSH key', { targetUsername, keyWithoutComment });
65-
await db.addPublicKey(targetUsername, keyWithoutComment);
66-
res.status(201).json({ message: 'SSH key added successfully' });
67-
} catch (error) {
113+
console.log('Adding SSH key', { targetUsername, fingerprint });
114+
try {
115+
await db.addPublicKey(targetUsername, publicKeyRecord);
116+
res.status(201).json({
117+
message: 'SSH key added successfully',
118+
fingerprint: fingerprint,
119+
});
120+
} catch (error: any) {
68121
console.error('Error adding SSH key:', error);
69122

70-
if (error instanceof DuplicateSSHKeyError) {
71-
res.status(409).json({ error: error.message });
72-
return;
73-
}
74-
75-
if (error instanceof UserNotFoundError) {
76-
res.status(404).json({ error: error.message });
77-
return;
123+
// Return specific error message
124+
if (error.message === 'SSH key already exists') {
125+
res.status(409).json({ error: 'This SSH key already exists' });
126+
} else if (error.message === 'User not found') {
127+
res.status(404).json({ error: 'User not found' });
128+
} else {
129+
res.status(500).json({ error: error.message || 'Failed to add SSH key' });
78130
}
79-
80-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
81-
res.status(500).json({ error: `Failed to add SSH key: ${errorMessage}` });
82131
}
83132
});
84133

85-
// Remove SSH public key
86-
router.delete('/:username/ssh-keys', async (req: Request, res: Response) => {
134+
// Remove SSH public key by fingerprint
135+
router.delete('/:username/ssh-keys/:fingerprint', async (req: Request, res: Response) => {
87136
if (!req.user) {
88-
res.status(401).json({ error: 'Login required' });
137+
res.status(401).json({ error: 'Authentication required' });
89138
return;
90139
}
91140

92141
const { username, admin } = req.user as { username: string; admin: boolean };
93142
const targetUsername = req.params.username.toLowerCase();
143+
const fingerprint = req.params.fingerprint;
94144

95145
// Only allow users to remove keys from their own account, or admins to remove from any account
96146
if (username !== targetUsername && !admin) {
97147
res.status(403).json({ error: 'Not authorized to remove keys for this user' });
98148
return;
99149
}
100150

101-
const { publicKey } = req.body;
102-
if (!publicKey) {
103-
res.status(400).json({ error: 'Public key is required' });
104-
return;
105-
}
106-
151+
console.log('Removing SSH key', { targetUsername, fingerprint });
107152
try {
108-
await db.removePublicKey(targetUsername, publicKey);
153+
await db.removePublicKey(targetUsername, fingerprint);
109154
res.status(200).json({ message: 'SSH key removed successfully' });
110-
} catch (error) {
155+
} catch (error: any) {
111156
console.error('Error removing SSH key:', error);
112-
res.status(500).json({ error: 'Failed to remove SSH key' });
157+
158+
// Return specific error message
159+
if (error.message === 'User not found') {
160+
res.status(404).json({ error: 'User not found' });
161+
} else {
162+
res.status(500).json({ error: error.message || 'Failed to remove SSH key' });
163+
}
113164
}
114165
});
115166

src/ui/views/User/UserProfile.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ import {
2525
DialogContent,
2626
DialogActions,
2727
} from '@material-ui/core';
28-
import { UserContextType } from '../RepoDetails/RepoDetails';
2928
import { getSSHKeys, addSSHKey, deleteSSHKey, SSHKey } from '../../services/ssh';
3029
import Snackbar from '../../components/Snackbar/Snackbar';
30+
import { UserContextType } from '../RepoDetails/RepoDetails';
3131

3232
const useStyles = makeStyles((theme: Theme) => ({
3333
root: {
@@ -82,10 +82,10 @@ export default function UserProfile(): React.ReactElement {
8282

8383
// Load SSH keys when data is available
8484
useEffect(() => {
85-
if (data && (isProfile || isAdmin)) {
85+
if (data && (isOwnProfile || loggedInUser?.admin)) {
8686
loadSSHKeys();
8787
}
88-
}, [data, isProfile, isAdmin, loadSSHKeys]);
88+
}, [data, isOwnProfile, loggedInUser, loadSSHKeys]);
8989

9090
const showSnackbar = (message: string, color: 'success' | 'danger') => {
9191
setSnackbarMessage(message);
@@ -190,11 +190,7 @@ export default function UserProfile(): React.ReactElement {
190190
padding: '20px',
191191
}}
192192
>
193-
<GridContainer
194-
style={{
195-
paddingTop: '10px',
196-
}}
197-
>
193+
<GridContainer>
198194
{data.gitAccount && (
199195
<GridItem xs={1} sm={1} md={1}>
200196
<img
@@ -240,7 +236,7 @@ export default function UserProfile(): React.ReactElement {
240236
)}
241237
</GridItem>
242238
</GridContainer>
243-
{isOwnProfile || loggedInUser.admin ? (
239+
{isOwnProfile || loggedInUser?.admin ? (
244240
<div style={{ marginTop: '50px' }}>
245241
<hr style={{ opacity: 0.2 }} />
246242
<div style={{ marginTop: '25px' }}>

0 commit comments

Comments
 (0)