package webauthn import ( "crypto/sha256" "encoding/base64" "errors" "fmt" ) // The WebAuthn PRF extension is where cold-start unlock gets its secret // (Vikunja #14). The authenticator evaluates a keyed PRF over a salt we // choose and returns 32 bytes that are: // // - stable — the same credential and the same salt always give the same // bytes, which is what lets a blob wrapped today be opened tomorrow; // - secret — they never leave the authenticator except as this output, so // unlike the credential public key they are not sitting in passkeys.json; // - bound to user verification — the assertion that produces them required // a gesture, so the bytes cannot be harvested silently. // // The salt is fixed and public. It is a domain separator, not a secret: it // makes maven's PRF output different from any other relying party's use of // the same credential. // prfSaltInput — the string hashed into the 32-byte evaluation salt. Changing // it invalidates every wrapped key file in existence, which is why it is a // constant and not configuration. const prfSaltInput = "maven-coldstart-unlock-v1" // PRFSalt returns the fixed 32-byte PRF evaluation salt. func PRFSalt() []byte { sum := sha256.Sum256([]byte(prfSaltInput)) return sum[:] } // ErrNoPRF is returned when a browser reports no PRF result — either the // authenticator does not implement the extension, or the platform stripped // it. Cold-start unlock is unavailable for that credential, and the correct // response is to say so rather than to fall back to something weaker. var ErrNoPRF = errors.New("webauthn: authenticator returned no PRF result (cold-start unlock unavailable)") // DecodePRFResult parses the base64url PRF output the browser read out of // getClientExtensionResults().prf.results.first and checks it is usable as // wrapping key material. // // The browser is not trusted to send something sensible: a short, empty, or // all-zero result would silently produce a blob that anyone can open, so all // three are refused here rather than at the crypto layer. func DecodePRFResult(b64 string) ([]byte, error) { if b64 == "" { return nil, ErrNoPRF } secret, err := decodeB64Any(b64) if err != nil { return nil, fmt.Errorf("webauthn: prf result: %w", err) } if err := checkSecret(secret); err != nil { return nil, err } return secret, nil } // decodeB64Any accepts padded or unpadded base64url — browsers differ, and // the JS helper on the passkey page strips padding. func decodeB64Any(s string) ([]byte, error) { if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { return b, nil } return base64.URLEncoding.DecodeString(s) }