Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions proof/passkey/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import (
"github.com/meigma/authkit"
)

// Defensive copies in this file protect ceremony state and credential records
// from in-place mutation after they cross the package boundary. The upstream
// webauthn.Credential carries several mutable byte slices and maps that
// callers could otherwise overwrite — corrupting stored credentials,
// invalidating clone-warning detection, or skewing the next verification.

// cloneConfig returns a deep copy of config so a Service can outlive any
// caller-owned mutation of the original Config struct.
func cloneConfig(config Config) Config {
return Config{
RPID: config.RPID,
Expand All @@ -18,6 +26,7 @@ func cloneConfig(config Config) Config {
}
}

// cloneUser returns a deep copy of user, including the Handle byte slice.
func cloneUser(user User) User {
return User{
RPID: user.RPID,
Expand All @@ -28,6 +37,8 @@ func cloneUser(user User) User {
}
}

// cloneCredential returns a deep copy of credential, including all mutable
// byte slices in the embedded webauthn.Credential.
func cloneCredential(credential Credential) Credential {
return Credential{
RPID: credential.RPID,
Expand All @@ -38,6 +49,8 @@ func cloneCredential(credential Credential) Credential {
}
}

// cloneRegistration returns a deep copy of registration so the Store layer
// can hand records back to callers without sharing internal slices.
func cloneRegistration(registration Registration) Registration {
return Registration{
User: cloneUser(registration.User),
Expand All @@ -46,6 +59,8 @@ func cloneRegistration(registration Registration) Registration {
}
}

// cloneCredentials returns a deep copy of credentials. Returns nil for an
// empty input to avoid distinguishing nil from a zero-length slice.
func cloneCredentials(credentials []Credential) []Credential {
if len(credentials) == 0 {
return nil
Expand All @@ -58,6 +73,11 @@ func cloneCredentials(credentials []Credential) []Credential {
return clones
}

// cloneWebAuthnCredential returns a deep copy of the upstream webauthn.Credential
// covering every mutable byte slice it carries (ID, PublicKey, Transport,
// AAGUID, and the four Attestation blobs). Missing any of these would leave a
// shared backing array reachable through both the stored record and the value
// handed back to callers.
func cloneWebAuthnCredential(credential webauthn.Credential) webauthn.Credential {
clone := credential
clone.ID = cloneBytes(credential.ID)
Expand All @@ -72,6 +92,7 @@ func cloneWebAuthnCredential(credential webauthn.Credential) webauthn.Credential
return clone
}

// cloneIdentity returns a deep copy of identity, including the Claims map.
func cloneIdentity(identity authkit.Identity) authkit.Identity {
clone := identity
if identity.Claims != nil {
Expand All @@ -82,6 +103,7 @@ func cloneIdentity(identity authkit.Identity) authkit.Identity {
return clone
}

// cloneBytes returns a defensive copy of value, returning nil for empty input.
func cloneBytes(value []byte) []byte {
if len(value) == 0 {
return nil
Expand All @@ -92,6 +114,7 @@ func cloneBytes(value []byte) []byte {
return clone
}

// cloneStrings returns a defensive copy of values, returning nil for empty input.
func cloneStrings(values []string) []string {
if len(values) == 0 {
return nil
Expand Down
9 changes: 9 additions & 0 deletions proof/passkey/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ var (
ErrCloneWarning = errors.New("passkey: clone warning")
)

// unauthenticated wraps reason as an [authkit.ErrUnauthenticated] so callers
// can detect auth failures with [errors.Is] without inspecting message strings.
func unauthenticated(reason string) error {
return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason)
}

// cloneWarning returns the error that signals an authenticator clone-warning
// rejection. The error chain carries both authkit.ErrUnauthenticated (so the
// caller treats it as a login failure) and ErrCloneWarning (so the caller can
// surface remediation guidance specific to a possibly-cloned credential).
func cloneWarning() error {
return fmt.Errorf("%w: %w", authkit.ErrUnauthenticated, ErrCloneWarning)
}

// internalError wraps err as an [authkit.ErrInternal] annotated with the
// failing operation name so collaborator failures stay distinguishable from
// caller-facing rejections.
func internalError(op string, err error) error {
return fmt.Errorf("%w: passkey: %s: %w", authkit.ErrInternal, op, err)
}
Loading