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.
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",