detached 845634b

feat(domain): add passkeys and the passkey name rules

commit
845634bcbb30b461c3a68ce09c9d4d2052dd3c5c
author
brice · · 2026-10-12 14:49 UTC
parent
fc4d564
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +139 -0
A internal/domain/passkey.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+const (
11+ // MaxPasskeys is the most passkeys the owner may register.
12+ MaxPasskeys = 20
13+ // MaxPasskeyIDLen is the longest credential id, in bytes (the WebAuthn
14+ // limit).
15+ MaxPasskeyIDLen = 1023
16+ // MaxPasskeyNameLen is the longest passkey name, in bytes.
17+ MaxPasskeyNameLen = 64
18+ // DefaultPasskeyName names a passkey registered without a name.
19+ DefaultPasskeyName = "passkey"
20+)
21+
22+// ErrPasskeyCloned means a passkey signed in with a counter that did not
23+// advance, which hints at a cloned authenticator (ADR 0026). It is an
24+// ErrUnauthenticated.
25+var ErrPasskeyCloned = fmt.Errorf("%w: passkey counter did not advance (possible clone)", ErrUnauthenticated)
26+
27+// Passkey is a WebAuthn credential that can sign the owner in. It holds the
28+// public key only.
29+type Passkey struct {
30+ // ID is the credential id: at most MaxPasskeyIDLen bytes, never empty.
31+ ID []byte
32+ Name string
33+ // PublicKey is the COSE public key.
34+ PublicKey []byte
35+ AttestationType string
36+ Transports []string
37+ // SignCount is the last counter the authenticator reported; 0 for
38+ // authenticators that have none (synced passkeys).
39+ SignCount uint32
40+ // BackupEligible and BackupState are the WebAuthn flags: the credential
41+ // can be backed up, and currently is (a synced passkey).
42+ BackupEligible bool
43+ BackupState bool
44+ CreatedAt time.Time
45+ LastUsedAt time.Time // zero until first used
46+}
47+
48+// PasskeyName is a validated passkey name: one line of plain text, 1 to
49+// MaxPasskeyNameLen bytes, not blank. Only ParsePasskeyName builds a non-zero
50+// value.
51+type PasskeyName struct{ s string }
52+
53+// ParsePasskeyName validates s with the rules of the other one-line texts: valid
54+// UTF-8, no control or bidirectional override character, and not blank.
55+func ParsePasskeyName(s string) (PasskeyName, error) {
56+ if len(s) > MaxPasskeyNameLen {
57+ return PasskeyName{}, &TextError{Reason: fmt.Sprintf("is longer than %d bytes (%d)", MaxPasskeyNameLen, len(s))}
58+ }
59+ if reason := textProblem(s, false, MaxPasskeyNameLen); reason != "" {
60+ return PasskeyName{}, &TextError{Reason: reason}
61+ }
62+ if isBlank(s) {
63+ return PasskeyName{}, &TextError{Reason: "is empty"}
64+ }
65+ return PasskeyName{s: s}, nil
66+}
67+
68+// String returns the name.
69+func (n PasskeyName) String() string { return n.s }
70+
71+// IsZero reports whether n is the zero value.
72+func (n PasskeyName) IsZero() bool { return n.s == "" }
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+const (
11+ // MaxPasskeys is the most passkeys the owner may register.
12+ MaxPasskeys = 20
13+ // MaxPasskeyIDLen is the longest credential id, in bytes (the WebAuthn
14+ // limit).
15+ MaxPasskeyIDLen = 1023
16+ // MaxPasskeyNameLen is the longest passkey name, in bytes.
17+ MaxPasskeyNameLen = 64
18+ // DefaultPasskeyName names a passkey registered without a name.
19+ DefaultPasskeyName = "passkey"
20+)
21+
22+// ErrPasskeyCloned means a passkey signed in with a counter that did not
23+// advance, which hints at a cloned authenticator (ADR 0026). It is an
24+// ErrUnauthenticated.
25+var ErrPasskeyCloned = fmt.Errorf("%w: passkey counter did not advance (possible clone)", ErrUnauthenticated)
26+
27+// Passkey is a WebAuthn credential that can sign the owner in. It holds the
28+// public key only.
29+type Passkey struct {
30+ // ID is the credential id: at most MaxPasskeyIDLen bytes, never empty.
31+ ID []byte
32+ Name string
33+ // PublicKey is the COSE public key.
34+ PublicKey []byte
35+ AttestationType string
36+ Transports []string
37+ // SignCount is the last counter the authenticator reported; 0 for
38+ // authenticators that have none (synced passkeys).
39+ SignCount uint32
40+ // BackupEligible and BackupState are the WebAuthn flags: the credential
41+ // can be backed up, and currently is (a synced passkey).
42+ BackupEligible bool
43+ BackupState bool
44+ CreatedAt time.Time
45+ LastUsedAt time.Time // zero until first used
46+}
47+
48+// PasskeyName is a validated passkey name: one line of plain text, 1 to
49+// MaxPasskeyNameLen bytes, not blank. Only ParsePasskeyName builds a non-zero
50+// value.
51+type PasskeyName struct{ s string }
52+
53+// ParsePasskeyName validates s with the rules of the other one-line texts: valid
54+// UTF-8, no control or bidirectional override character, and not blank.
55+func ParsePasskeyName(s string) (PasskeyName, error) {
56+ if len(s) > MaxPasskeyNameLen {
57+ return PasskeyName{}, &TextError{Reason: fmt.Sprintf("is longer than %d bytes (%d)", MaxPasskeyNameLen, len(s))}
58+ }
59+ if reason := textProblem(s, false, MaxPasskeyNameLen); reason != "" {
60+ return PasskeyName{}, &TextError{Reason: reason}
61+ }
62+ if isBlank(s) {
63+ return PasskeyName{}, &TextError{Reason: "is empty"}
64+ }
65+ return PasskeyName{s: s}, nil
66+}
67+
68+// String returns the name.
69+func (n PasskeyName) String() string { return n.s }
70+
71+// IsZero reports whether n is the zero value.
72+func (n PasskeyName) IsZero() bool { return n.s == "" }
A internal/domain/passkey_test.go +67 -0
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestParsePasskeyName(t *testing.T) {
16+ t.Parallel()
17+ tests := []struct {
18+ name string
19+ in string
20+ reason string // "" when valid
21+ }{
22+ {"plain", "Pixel 9", ""},
23+ {"unicode", "clé de ma mère 🔑", ""},
24+ {"one byte", "a", ""},
25+ {"max bytes", strings.Repeat("a", domain.MaxPasskeyNameLen), ""},
26+ {"too long", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "longer than 64"},
27+ {"too long in bytes, short in characters", strings.Repeat("é", 33), "longer than 64 bytes"},
28+ {"empty", "", "is empty"},
29+ {"blank", " \u00a0 ", "is empty"},
30+ {"line break", "two\nlines", "one line"},
31+ {"tab", "a\tb", "control character U+0009"},
32+ {"nul", "a\x00b", "control character U+0000"},
33+ {"escape", "\x1b[31mred", "control character U+001B"},
34+ {"bidi override", "evil \u202e txt.exe", "U+202E"},
35+ {"bidi isolate", "a\u2066b", "U+2066"},
36+ {"line separator", "a\u2028b", "U+2028"},
37+ {"invalid utf-8", "a\xffb", "not valid UTF-8"},
38+ }
39+ for _, tc := range tests {
40+ t.Run(tc.name, func(t *testing.T) {
41+ t.Parallel()
42+ got, err := domain.ParsePasskeyName(tc.in)
43+ if tc.reason == "" {
44+ require.NoError(t, err)
45+ assert.Equal(t, tc.in, got.String())
46+ assert.False(t, got.IsZero())
47+ return
48+ }
49+ require.Error(t, err)
50+ assert.ErrorIs(t, err, domain.ErrInvalidText)
51+ assert.True(t, domain.IsInvalid(err))
52+ assert.Contains(t, err.Error(), tc.reason)
53+ assert.True(t, got.IsZero())
54+ })
55+ }
56+}
57+
58+func TestDefaultPasskeyNameIsValid(t *testing.T) {
59+ t.Parallel()
60+ _, err := domain.ParsePasskeyName(domain.DefaultPasskeyName)
61+ require.NoError(t, err)
62+}
63+
64+func TestErrPasskeyClonedIsUnauthenticated(t *testing.T) {
65+ t.Parallel()
66+ assert.ErrorIs(t, domain.ErrPasskeyCloned, domain.ErrUnauthenticated)
67+}
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestParsePasskeyName(t *testing.T) {
16+ t.Parallel()
17+ tests := []struct {
18+ name string
19+ in string
20+ reason string // "" when valid
21+ }{
22+ {"plain", "Pixel 9", ""},
23+ {"unicode", "clé de ma mère 🔑", ""},
24+ {"one byte", "a", ""},
25+ {"max bytes", strings.Repeat("a", domain.MaxPasskeyNameLen), ""},
26+ {"too long", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "longer than 64"},
27+ {"too long in bytes, short in characters", strings.Repeat("é", 33), "longer than 64 bytes"},
28+ {"empty", "", "is empty"},
29+ {"blank", " \u00a0 ", "is empty"},
30+ {"line break", "two\nlines", "one line"},
31+ {"tab", "a\tb", "control character U+0009"},
32+ {"nul", "a\x00b", "control character U+0000"},
33+ {"escape", "\x1b[31mred", "control character U+001B"},
34+ {"bidi override", "evil \u202e txt.exe", "U+202E"},
35+ {"bidi isolate", "a\u2066b", "U+2066"},
36+ {"line separator", "a\u2028b", "U+2028"},
37+ {"invalid utf-8", "a\xffb", "not valid UTF-8"},
38+ }
39+ for _, tc := range tests {
40+ t.Run(tc.name, func(t *testing.T) {
41+ t.Parallel()
42+ got, err := domain.ParsePasskeyName(tc.in)
43+ if tc.reason == "" {
44+ require.NoError(t, err)
45+ assert.Equal(t, tc.in, got.String())
46+ assert.False(t, got.IsZero())
47+ return
48+ }
49+ require.Error(t, err)
50+ assert.ErrorIs(t, err, domain.ErrInvalidText)
51+ assert.True(t, domain.IsInvalid(err))
52+ assert.Contains(t, err.Error(), tc.reason)
53+ assert.True(t, got.IsZero())
54+ })
55+ }
56+}
57+
58+func TestDefaultPasskeyNameIsValid(t *testing.T) {
59+ t.Parallel()
60+ _, err := domain.ParsePasskeyName(domain.DefaultPasskeyName)
61+ require.NoError(t, err)
62+}
63+
64+func TestErrPasskeyClonedIsUnauthenticated(t *testing.T) {
65+ t.Parallel()
66+ assert.ErrorIs(t, domain.ErrPasskeyCloned, domain.ErrUnauthenticated)
67+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close