feat(domain): add passkeys and the passkey name rules
- commit
- 845634bcbb30b461c3a68ce09c9d4d2052dd3c5c
- author
- brice · · 2026-10-12 14:49 UTC
- parent
- fc4d564
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||