Gitfed
bastien-mrq/gitfed/ Commits/ f0fd6b7

Show SSH key fingerprints on public profile pages

New sshFingerprints (golang.org/x/crypto/ssh's FingerprintSHA256, the standard ssh-keygen -lf format) — lets a visitor verify "is this the same key I already trust" without the page ever handing out the full public key material. A key that fails to parse is silently skipped rather than shown broken.

bastien-mrq 2026-07-30 14:05 commit f0fd6b72b14a1fa8b64e46e47038e064ee1af5dc parent f75a9d3b9c3a49ca2190b15971ade0b081a5f690
6 files changed +72 −6
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −5
M cmd/gitfed-web/handlers_profile.go +29 −1
A cmd/gitfed-web/handlers_profile_test.go +35 −0
M internal/i18n/strings_en.go +2 −0
M internal/i18n/strings_fr.go +2 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 01938de..bb5f1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.23 + +- Public profile pages now show each account's SSH key fingerprints (SHA256, the standard `ssh-keygen -lf` format) — lets a visitor verify "is this the same key I already trust" without the page ever handing out the full public key material. + ## 1.2.22 - Repo page now shows a small stats line: commit count, contributor count (distinct commit-author emails), and a guessed dominant language (by file extension count in the default branch's tree — a lightweight heuristic, not a real linguist-style byte-size analysis). New `CountContributors`/`DominantLanguage` on `admin.Ops`.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index 450855f..b033cd9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -108,11 +108,6 @@ celui-là la prochaine fois qu'on rouvre ce document. déjà pour les invitations fédérées, à la même logique. *(effort moyen, pertinent dès que plusieurs personnes collaborent sur un même dépôt)*. -- **Fingerprint des clés SSH publiques sur la page de profil** (voir - §4) — affichage du fingerprint seul, jamais la clé complète ; permet - de vérifier l'identité d'un contributeur sans exposer de matériel - sensible. Pas fait avec la première version des pages de profil, - faute de besoin confirmé. ### Organisation à plus grande échelle
cmd/gitfed-web/handlers_profile.go
diff --git a/cmd/gitfed-web/handlers_profile.go b/cmd/gitfed-web/handlers_profile.go index 1f9ec39..c9755ea 100644 --- a/cmd/gitfed-web/handlers_profile.go +++ b/cmd/gitfed-web/handlers_profile.go @@ -6,6 +6,8 @@ import ( "net/http" "sort" + gossh "golang.org/x/crypto/ssh" + "git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec" "git.neuromancer.ovh/bastien-mrq/gitfed/internal/store" ) @@ -26,6 +28,23 @@ type profileActivity struct { Commit gitexec.Commit } +// sshFingerprints turns a user's stored authorized_keys-format public keys +// into their SHA256 fingerprints (the standard `ssh-keygen -lf` format) for +// display on the *public* profile page — the fingerprint alone lets a +// visitor verify "is this the same key I already trust" without the page +// ever handing out the full public key material. +func sshFingerprints(pubKeys []string) []string { + fps := make([]string, 0, len(pubKeys)) + for _, k := range pubKeys { + key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(k)) + if err != nil { + continue // a key that fails to parse just doesn't get shown + } + fps = append(fps, gossh.FingerprintSHA256(key)) + } + return fps +} + var profileTpl = newTpl("profile", ` {{.Flash}} <div class="gf-profile-page-head"> @@ -42,6 +61,14 @@ var profileTpl = newTpl("profile", ` </div> {{end}} +{{if .KeyFingerprints}} +<div class="gf-page-head"><h2>{{t .Lang "profile.keys_title"}}</h2></div> +<div class="gf-card" style="padding:1rem 1.2rem;"> + <p class="muted" style="margin:0 0 0.6rem;">{{t .Lang "profile.keys_hint"}}</p> + {{range .KeyFingerprints}}<div class="muted" style="font-family:var(--mono); font-size:0.84rem; margin:0.2rem 0;">{{icon "key"}} {{.}}</div>{{end}} +</div> +{{end}} + <div class="gf-page-head"><h2>{{t .Lang "profile.repos_title"}}</h2><span class="count">{{len .Repos}} {{t .Lang "explore.repo_count"}}</span></div> <div class="gf-card"> {{if .Repos}} @@ -132,10 +159,11 @@ func (s *server) handleUserProfile(w http.ResponseWriter, r *http.Request) { BioHTML template.HTML Repos []store.Repo Activity []profileActivity + KeyFingerprints []string Flash template.HTML }{ user.Username, string(lang), initials(user.Username), user.CreatedAt.Format("2006-01-02"), - user.IsAdmin, bioHTML, repos, activity, flash(r), + user.IsAdmin, bioHTML, repos, activity, sshFingerprints(user.PubKeys), flash(r), }) s.render(w, r, user.Username, "", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_profile_test.go
diff --git a/cmd/gitfed-web/handlers_profile_test.go b/cmd/gitfed-web/handlers_profile_test.go new file mode 100644 index 0000000..2a9d173 --- /dev/null +++ b/cmd/gitfed-web/handlers_profile_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "strings" + "testing" +) + +func TestSSHFingerprints(t *testing.T) { + // A real, valid ed25519 public key (freshly generated for this test, + // not used anywhere real). + const validKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO+a8ua6dKn3LQLzMacuGeN8v7cH4HiuICxhULf5Xdxr test@example" + + fps := sshFingerprints([]string{validKey, "not a valid key", validKey}) + + if len(fps) != 2 { + t.Fatalf("expected 2 fingerprints (invalid key skipped), got %d: %+v", len(fps), fps) + } + for _, fp := range fps { + if !strings.HasPrefix(fp, "SHA256:") { + t.Errorf("expected fingerprint to start with %q, got %q", "SHA256:", fp) + } + if strings.Contains(fp, "AAAAC3NzaC1lZDI1NTE5") { + t.Errorf("fingerprint must never contain the raw key material, got %q", fp) + } + } + if fps[0] != fps[1] { + t.Errorf("the same key should always produce the same fingerprint, got %q and %q", fps[0], fps[1]) + } +} + +func TestSSHFingerprintsEmpty(t *testing.T) { + if fps := sshFingerprints(nil); len(fps) != 0 { + t.Errorf("expected no fingerprints for no keys, got %+v", fps) + } +}
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index c1a29e0..c03dd70 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -37,6 +37,8 @@ var en = map[string]string{ // ---------- profile ---------- "profile.joined": "Joined", + "profile.keys_title": "Public keys", + "profile.keys_hint": "SHA256 fingerprints only — never the full key.", "profile.repos_title": "Public repositories", "profile.no_repos": "No public repositories yet.", "profile.activity_title": "Recent activity",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 7b9128d..bcbaf10 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -37,6 +37,8 @@ var fr = map[string]string{ // ---------- profile ---------- "profile.joined": "Inscrit·e depuis", + "profile.keys_title": "Clés publiques", + "profile.keys_hint": "Empreintes SHA256 uniquement — jamais la clé complète.", "profile.repos_title": "Dépôts publics", "profile.no_repos": "Aucun dépôt public pour l'instant.", "profile.activity_title": "Activité récente",