Skip to content

Commit 2561d61

Browse files
author
Nils Bars
committed
Name downloaded SSH keys after their actual algorithm
Registration generates ed25519 pairs but the Content-Disposition on the student key downloads and the `download` attributes in KeyDownloadCard were hardcoded to `id_rsa`, so users saved ed25519 keys under an RSA filename. Add `ssh_key_basename()` in `ref.core.util` and a TS twin in the SPA to pick the name ssh-keygen would default to (id_ed25519 / id_ecdsa / id_rsa / id_dsa), and route both download endpoints and the card through it. Also switch admin bootstrap to ed25519 so a freshly seeded admin user matches the rest of the system.
1 parent b395162 commit 2561d61

5 files changed

Lines changed: 108 additions & 11 deletions

File tree

spa-frontend/src/components/KeyDownloadCard.vue

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue';
2+
import { computed, ref } from 'vue';
33
import type { KeyResult } from '../api/registration';
44
55
const props = withDefaults(
@@ -10,6 +10,21 @@ const props = withDefaults(
1010
const pubCopied = ref(false);
1111
const privCopied = ref(false);
1212
13+
// Pick the filename `ssh-keygen` would default to for this key's algorithm
14+
// so downloaded files reflect the actual key type (ed25519/ecdsa/rsa/dsa).
15+
function sshKeyBasename(pubkey: string | null | undefined): string {
16+
if (!pubkey) return 'id_rsa';
17+
const algo = pubkey.trim().split(/\s+/, 1)[0] ?? '';
18+
if (algo === 'ssh-ed25519') return 'id_ed25519';
19+
if (algo.startsWith('ecdsa-sha2-')) return 'id_ecdsa';
20+
if (algo === 'ssh-dss') return 'id_dsa';
21+
return 'id_rsa';
22+
}
23+
24+
const keyBasename = computed(() => sshKeyBasename(props.result.pubkey));
25+
const pubkeyFilename = computed(() => `${keyBasename.value}.pub`);
26+
const privkeyFilename = computed(() => keyBasename.value);
27+
1328
async function copy(text: string | null, flag: 'pub' | 'priv') {
1429
if (!text) return;
1530
try {
@@ -93,11 +108,11 @@ const hasPrivkey = !!props.result.privkey;
93108
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem">
94109
<v-btn
95110
:href="props.result.pubkey_url"
96-
download="id_rsa.pub"
111+
:download="pubkeyFilename"
97112
prepend-icon="mdi-download"
98113
size="small"
99114
>
100-
Download id_rsa.pub
115+
Download {{ pubkeyFilename }}
101116
</v-btn>
102117
<v-btn
103118
size="small"
@@ -124,11 +139,11 @@ const hasPrivkey = !!props.result.privkey;
124139
<v-btn
125140
v-if="props.result.privkey_url"
126141
:href="props.result.privkey_url"
127-
download="id_rsa"
142+
:download="privkeyFilename"
128143
prepend-icon="mdi-download"
129144
size="small"
130145
>
131-
Download id_rsa
146+
Download {{ privkeyFilename }}
132147
</v-btn>
133148
<v-btn
134149
size="small"

tests/unit/test_util.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
from unittest.mock import MagicMock, patch
99
from colorama import Fore, Style
1010

11-
from ref.core.util import AnsiColorUtil, is_db_serialization_error, is_deadlock_error
11+
from ref.core.util import (
12+
AnsiColorUtil,
13+
is_db_serialization_error,
14+
is_deadlock_error,
15+
ssh_key_basename,
16+
)
1217

1318

1419
@pytest.mark.offline
@@ -223,3 +228,43 @@ def test_multiline_text(self):
223228
result = AnsiColorUtil.green(multiline)
224229
# The entire multiline text should be wrapped, not each line
225230
assert result == f"{Fore.GREEN}{multiline}{Style.RESET_ALL}"
231+
232+
233+
@pytest.mark.offline
234+
class TestSshKeyBasename:
235+
"""Filename mapping for OpenSSH public keys."""
236+
237+
def test_ed25519(self):
238+
assert (
239+
ssh_key_basename("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 comment") == "id_ed25519"
240+
)
241+
242+
def test_rsa(self):
243+
assert ssh_key_basename("ssh-rsa AAAAB3NzaC1yc2E comment") == "id_rsa"
244+
245+
def test_ecdsa_nistp256(self):
246+
assert (
247+
ssh_key_basename("ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY=")
248+
== "id_ecdsa"
249+
)
250+
251+
def test_ecdsa_nistp521(self):
252+
assert ssh_key_basename("ecdsa-sha2-nistp521 AAAA") == "id_ecdsa"
253+
254+
def test_dsa(self):
255+
assert ssh_key_basename("ssh-dss AAAAB3NzaC1kc3M=") == "id_dsa"
256+
257+
def test_none(self):
258+
assert ssh_key_basename(None) == "id_rsa"
259+
260+
def test_empty_string(self):
261+
assert ssh_key_basename("") == "id_rsa"
262+
263+
def test_whitespace_only(self):
264+
assert ssh_key_basename(" \n") == "id_rsa"
265+
266+
def test_leading_whitespace_is_stripped(self):
267+
assert ssh_key_basename(" ssh-ed25519 AAAA") == "id_ed25519"
268+
269+
def test_unknown_algo_falls_back_to_rsa(self):
270+
assert ssh_key_basename("bogus-algo AAAA") == "id_rsa"

webapp/ref/__init__.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
from types import MethodType
99

1010
from Crypto.PublicKey import RSA, ECC
11+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12+
from cryptography.hazmat.primitives.serialization import (
13+
Encoding,
14+
NoEncryption,
15+
PrivateFormat,
16+
PublicFormat,
17+
)
1118
from flask import Blueprint, Flask, current_app, g, render_template, request, url_for
1219
from flask.logging import default_handler, wsgi_errors_stream
1320
from flask_limiter import Limiter
@@ -247,9 +254,15 @@ def setup_db_default_data(app: Flask):
247254
admin.pub_key = admin.pub_key.decode()
248255
admin.priv_key = None
249256
else:
250-
key = RSA.generate(2048)
251-
admin.pub_key = key.export_key(format="OpenSSH").decode()
252-
admin.priv_key = key.export_key().decode()
257+
key = Ed25519PrivateKey.generate()
258+
admin.pub_key = (
259+
key.public_key()
260+
.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH)
261+
.decode()
262+
)
263+
admin.priv_key = key.private_bytes(
264+
Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption()
265+
).decode()
253266

254267
with app.app_context():
255268
app.db.session.add(admin)

webapp/ref/core/util.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@
2222
_database_lock = RLock()
2323

2424

25+
def ssh_key_basename(pubkey: str | None) -> str:
26+
"""Return the conventional OpenSSH filename base for a public key line.
27+
28+
Maps the algorithm identifier in an OpenSSH-format public key (e.g.
29+
``ssh-ed25519 AAAA...``) to the filename ``ssh-keygen`` would pick by
30+
default (``id_ed25519``, ``id_rsa``, ``id_ecdsa``, ``id_dsa``). Unknown
31+
or missing keys fall back to ``id_rsa`` to preserve historical behaviour.
32+
"""
33+
if not pubkey:
34+
return "id_rsa"
35+
parts = pubkey.strip().split(None, 1)
36+
algo = parts[0] if parts else ""
37+
if algo == "ssh-ed25519":
38+
return "id_ed25519"
39+
if algo.startswith("ecdsa-sha2-"):
40+
return "id_ecdsa"
41+
if algo == "ssh-dss":
42+
return "id_dsa"
43+
return "id_rsa"
44+
45+
2546
class DatabaseLockTimeoutError(Exception):
2647
"""Raised when waiting for database lock exceeds timeout."""
2748

webapp/ref/view/student.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ref.core.logging import get_logger
3232
from ref.core.util import (
3333
redirect_to_next,
34+
ssh_key_basename,
3435
)
3536
from ref.model import GroupNameList, SystemSettingsManager, User, UserGroup
3637
from ref.model.enums import UserAuthorizationGroups
@@ -155,10 +156,11 @@ def student_download_pubkey(signed_mat: str):
155156

156157
student = User.query.filter(User.mat_num == mat_num).one_or_none()
157158
if student:
159+
filename = f"{ssh_key_basename(student.pub_key)}.pub"
158160
return Response(
159161
student.pub_key,
160162
mimetype="text/plain",
161-
headers={"Content-disposition": "attachment; filename=id_rsa.pub"},
163+
headers={"Content-disposition": f"attachment; filename={filename}"},
162164
)
163165

164166
flash.error("Unknown student")
@@ -185,10 +187,11 @@ def student_download_privkey(signed_mat: str):
185187

186188
student = User.query.filter(User.mat_num == mat_num).one_or_none()
187189
if student:
190+
filename = ssh_key_basename(student.pub_key)
188191
return Response(
189192
student.priv_key,
190193
mimetype="text/plain",
191-
headers={"Content-disposition": "attachment; filename=id_rsa"},
194+
headers={"Content-disposition": f"attachment; filename={filename}"},
192195
)
193196

194197
flash.error("Unknown student")

0 commit comments

Comments
 (0)