4eca20bd94
Cold-start unlock wrapped the database key under the credential *public* key.
A public key is public: mavweb writes it verbatim to passkeys.json, normally in
the same state dir as db_key.wrapped, so anyone holding both files recovered the
database key offline with no authenticator involved. The wrapped blob was a
plaintext key with extra steps.
The secret is now the WebAuthn PRF extension output — 32 bytes the authenticator
computes over a fixed salt and never stores anywhere. The blob gains a version:
v2: "MVNKW2\x00" || salt || nonce || AES-256-GCM(key), magic as AAD
v1: salt || nonce || AES-256-GCM(key) (read-only)
v1 still opens so an existing deployment is not bricked, and reports itself so
the daemon can log a SECURITY line telling him to re-enroll. Nothing writes v1.
The magic is authenticated, so a v2 blob cannot be stripped and re-read as v1.
Four other defects on the same path:
- The locked-boot store was opened on an IPC goroutine inside UnlockFn and
never closed. Close is what re-encrypts the tmpfs working copy back over
the ciphertext, so every write of a cold-started session was lost silently
on the next boot. daemonLock now owns the store and seals it at shutdown.
- MethodUnlock was reachable by anything on the box; the socket is same-uid
and cannot authenticate its caller. It now requires a passkey assertion
that mavweb verified first.
- Concurrent unlocks would each open a store and wire a daemon. One at a
time, and never a second one.
- The hand-rolled HKDF keyed the expand step with the salt instead of the
PRK. Replaced with crypto/hkdf.
Key wrapping moves from enrolment to the first assertion, because create() does
not produce a PRF result on most authenticators — only a support flag. An
authenticator without PRF now writes no wrapped file at all rather than one
that looks protected and is not, and the page says so.
Verified: make build, make test. New tests cover the v2 round trip, a wrong
secret, every single-bit tamper, truncation, the v1 downgrade attempt, legacy
v1 reads, non-32-byte and all-zero secrets, the ipc wire field, locked-mode
default-deny, a forged assertion never reaching the unlock path, seal-on-
shutdown after a cold start, and that nothing in the state dir contains the
plaintext key. The PRF round trip against real hardware is a QA step.
Vikunja #14
71 lines
2.6 KiB
Go
71 lines
2.6 KiB
Go
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)
|
|
}
|