From 45a5b0ebb1e02e5af22d8a39b956e582b58c6f04 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 11 Jan 2025 23:39:15 +0100 Subject: [PATCH] Add WebAuthn support and age-plugin-fido2prf --- README.md | 106 +++++++- fido2prf/cmd/age-plugin-fido2prf/main.go | 57 +++++ fido2prf/fido2prf.go | 250 +++++++++++++++++++ fido2prf/internal/ctap2cbor/cbor.go | 136 ++++++++++ go.mod | 15 ++ go.sum | 25 ++ lib/cbor.ts | 131 ++++++++++ lib/index.ts | 2 + lib/recipients.ts | 8 +- lib/webauthn.ts | 302 +++++++++++++++++++++++ package.json | 1 + typedoc.json | 1 + wwwdev/index.html | 28 +++ wwwdev/index.ts | 4 + 14 files changed, 1059 insertions(+), 7 deletions(-) create mode 100644 fido2prf/cmd/age-plugin-fido2prf/main.go create mode 100644 fido2prf/fido2prf.go create mode 100644 fido2prf/internal/ctap2cbor/cbor.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lib/cbor.ts create mode 100644 lib/webauthn.ts create mode 100644 wwwdev/index.html create mode 100644 wwwdev/index.ts diff --git a/README.md b/README.md index 770a189..627bfe1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ - The age logo, an wireframe of St. Peters dome in Rome, with the text: age, file encryption + The age logo, a wireframe of St. Peters dome in Rome, with the text: age, file encryption

@@ -12,6 +12,10 @@ implementation of the [age](https://age-encryption.org) file encryption format. It depends only on the [noble](https://paulmillr.com/noble/) cryptography libraries, and uses the Web Crypto API when available. +It also provides support for symmetric encryption using passkeys and hardware +security keys in the browser via WebAuthn, and an interoperable CLI plugin and +Go library for FIDO2 tokens. + ## Installation ```sh @@ -21,6 +25,7 @@ npm install age-encryption ## Usage `age-encryption` is a modern ES Module with built-in types. + It's compiled for ES2022, and compatible with Node.js 18+, Bun, Deno, and all recent browsers. #### Encrypt and decrypt a file with a new recipient / identity pair @@ -43,7 +48,7 @@ const out = await d.decrypt(ciphertext, "text") console.log(out) ``` -### ASCII armoring +#### ASCII armoring age encrypted files (the inputs of `Decrypter.decrypt` and outputs of `Encrypter.encrypt`) are binary files, of type `Uint8Array`. There is an official ASCII @@ -135,7 +140,102 @@ Then, you can use it like this ``` -### Web Crypto identities +#### Encrypt and decrypt a file with a passkey + +In the browser, `age-encryption` supports *symmetric* encryption with passkeys, +discoverable credentials that can be stored and synced by platforms (e.g. iCloud +Keychain) or password managers (e.g. 1Password). + +This functionality uses the WebAuthn PRF extension, which is supported by recent +browsers and authenticators. When encrypting or decrypting a file, the user will +be prompted to select a passkey associated with the replying party ID (usually +the website origin). Passkeys not generated by `createCredential` can be used if +they have the `prf` extension enabled. The identity string returned by +`createCredential` can be optionally provided at encryption/decryption time to +prevent the user from selecting other passkeys. + +```ts +await age.webauthn.createCredential({ keyName: "age encryption key 🦈" }) + +const e = new age.Encrypter() +e.addRecipient(new age.webauthn.WebAuthnRecipient()) +const ciphertext = await e.encrypt("Hello, age!") +const armored = age.armor.encode(ciphertext) +console.log(armored) + +const d = new age.Decrypter() +d.addIdentity(new age.webauthn.WebAuthnIdentity()) +const decoded = age.armor.decode(armored) +const out = await d.decrypt(decoded, "text") +console.log(out) +``` + +Each encryption and decryption operation requires the authenticator and user +confirmation, there is no extractable key, and encrypted files can't be linked +to an identity or to each other without the ability to decrypt them. + +#### Encrypt and decrypt a file with a security key + +`age-encryption` also supports non-discoverable FIDO2 credentials, usually +useful to encrypt files with hardware security keys (e.g. YubiKeys). + +Encryption and decryption work the same as with passkeys, but the identity +string is mandatory, because these credentials are not discoverable. + +```ts +const identity = await age.webauthn.createCredential({ + type: "security-key", keyName: "age encryption key" }) +console.log(identity) // AGE-PLUGIN-FIDO2PRF-1... + +const e = new age.Encrypter() +e.addRecipient(new age.webauthn.WebAuthnRecipient({ identity: identity })) +const ciphertext = await e.encrypt("Hello, age!") +const armored = age.armor.encode(ciphertext) +console.log(armored) + +const d = new age.Decrypter() +d.addIdentity(new age.webauthn.WebAuthnIdentity({ identity: identity })) +const decoded = age.armor.decode(armored) +const out = await d.decrypt(decoded, "text") +console.log(out) +``` + +##### age-plugin-fido2prf + +If a credential is associated with a USB FIDO2 security key (e.g. a YubiKey), +its identity string can be used outside the browser with the provided +`age-plugin-fido2prf` plugin. + +Files encrypted in the browser will decrypt from the CLI, and vice-versa. Since +WebAuthn encryption is symmetric, there is no recipient encoding, only +identities. To encrypt to an identity, use `age -e -i`. + +```sh +go install filippo.io/typage/fido2prf/cmd/age-plugin-fido2prf@latest + +cat << EOF > identity.txt +AGE-PLUGIN-FIDO2PRF-1Q9VGPY2E7S5FJJS3N7P03TZMMEJ94S6HYLDJLU8WVX2HP8SXQUGJUZ68LN6GP705662VS06UEX5J42W80NZT8Y2DQ8GTDN50VGATCNYLJ4HY2W5J67KYCTM858UFDCNUUDZ6U28WEMUKGVG9RNELRJDH8NFEP999Z8XFSS8XLS448A3TSQKWG9DMPL8ZCRRA02KSUC2UXTYDFNVYAE5KCMMRV9KXSMMNWJQKXATNVGTXV35G +EOF + +age -d -i identity.txt << EOF +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGFnZS1lbmNyeXB0aW9uLm9yZy9maWRv +MnByZiBJSFFHd0poUkNSYThuVnB6b1R1bjdBClJ3d3dRUUtiRjN4TkU0VWx1SUZU +WnJtTVBFUTZoR0d4eUx2WXFOSFBsQzQKLS0tIFNWVGRnNzV4L00wblRENUZyYlFh +WU5wQmVsdG5hL0lmcXhTVzZHTVRtdFkK2rYiueXr8dgM1GiLVrBMC/LQRzkDacMw +GEtVcMZyh7b90z6VR3KT92EIlA== +-----END AGE ENCRYPTED FILE----- +EOF +``` + +Credentials can be generated from the command line with `age-plugin-fido2prf +-generate RPID`. Note that they will be usable inside the browser only if the +relying party ID matches the website's origin. + +All the features of the plugin are also available as a Go library at +[filippo.io/typage/fido2prf](https://pkg.go.dev/filippo.io/typage/fido2prf). + +#### Web Crypto identities You can use a CryptoKey as an identity. It must have an `algorithm` of `X25519`, and support the `deriveBits` key usage. It doesn't need to be extractable. diff --git a/fido2prf/cmd/age-plugin-fido2prf/main.go b/fido2prf/cmd/age-plugin-fido2prf/main.go new file mode 100644 index 0000000..723bb07 --- /dev/null +++ b/fido2prf/cmd/age-plugin-fido2prf/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/typage/fido2prf" + "golang.org/x/term" +) + +// TODO: -list, to list resident credentials as identity strings. +// TODO: maybe support non-UV hmac-secret? + +func main() { + p, err := plugin.New("fido2prf") + if err != nil { + fmt.Printf("Error: %s\n", err) + os.Exit(1) + } + + generate := flag.String("generate", "", "Generate a new credential for the given relying party ID.") + p.RegisterFlags(nil) + flag.Parse() + + if *generate != "" { + fmt.Fprintf(os.Stderr, "Enter the security key PIN: ") + pin, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + fmt.Printf("Error reading the PIN: %s\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "\r\033[K") // Clear the line. + + identity, err := fido2prf.NewCredential(*generate, string(pin)) + if err != nil { + fmt.Printf("Error: %s\n", err) + os.Exit(1) + } + fmt.Println(identity) + return + } + + p.HandleIdentity(func(data []byte) (age.Identity, error) { + return fido2prf.NewIdentityFromData(data, func() (string, error) { + return p.RequestValue("Enter the security key PIN:", true) + }) + }) + p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) { + return fido2prf.NewIdentityFromData(data, func() (string, error) { + return p.RequestValue("Enter the security key PIN:", true) + }) + }) + os.Exit(p.Main()) +} diff --git a/fido2prf/fido2prf.go b/fido2prf/fido2prf.go new file mode 100644 index 0000000..59029cb --- /dev/null +++ b/fido2prf/fido2prf.go @@ -0,0 +1,250 @@ +package fido2prf + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/typage/fido2prf/internal/ctap2cbor" + "github.com/keys-pub/go-libfido2" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +func NewCredential(rpID, pin string) (string, error) { + locs, err := libfido2.DeviceLocations() + if err != nil { + return "", err + } + if len(locs) == 0 { + return "", errors.New("no FIDO2 devices found") + } + if len(locs) != 1 { + return "", errors.New("multiple FIDO2 devices found, please remove all but one") + } + device, err := libfido2.NewDevice(locs[0].Path) + if err != nil { + return "", err + } + a, err := device.MakeCredential( + // The client data hash is not useful without attestation. + bytes.Repeat([]byte{0}, 32), + libfido2.RelyingParty{ID: rpID}, + libfido2.User{ + // These are not used for non-resident credentials, + // but the Go wrapper requires them. + ID: []byte{0}, + Name: "age-encryption.org/fido2prf", + }, + libfido2.ES256, + pin, + &libfido2.MakeCredentialOpts{ + Extensions: []libfido2.Extension{libfido2.HMACSecretExtension}, + RK: libfido2.False, + UV: libfido2.True, + }) + if err != nil { + return "", err + } + var identity []byte + identity = ctap2cbor.AppendUint(identity, 1) + identity = ctap2cbor.AppendBytes(identity, a.CredentialID) + identity = ctap2cbor.AppendString(identity, rpID) + identity = ctap2cbor.AppendArray(identity, "usb") + return plugin.EncodeIdentity("fido2prf", identity), nil +} + +type Identity struct { + credentialID []byte + relyingParty string + transports []string + + getPIN func() (string, error) +} + +const label = "age-encryption.org/fido2prf" + +func (i *Identity) assert(nonce []byte) ([]byte, error) { + locs, err := libfido2.DeviceLocations() + if err != nil { + return nil, err + } + if len(locs) == 0 { + return nil, errors.New("no FIDO2 devices found") + } + for _, loc := range locs { + device, err := libfido2.NewDevice(loc.Path) + if err != nil { + return nil, err + } + + // First probe to check if the credential ID matches the device, + // before requiring user interaction. + if _, err := device.Assertion( + i.relyingParty, + make([]byte, 32), + [][]byte{i.credentialID}, + "", + &libfido2.AssertionOpts{ + UP: libfido2.False, + }, + ); errors.Is(err, libfido2.ErrNoCredentials) { + continue + } else if err != nil { + return nil, err + } + + pin, err := i.getPIN() + if err != nil { + return nil, err + } + + assertion, err := device.Assertion( + i.relyingParty, + make([]byte, 32), + [][]byte{i.credentialID}, + pin, + &libfido2.AssertionOpts{ + Extensions: []libfido2.Extension{libfido2.HMACSecretExtension}, + HMACSalt: hmacSecretSalt(nonce), + UV: libfido2.True, + }, + ) + if err != nil { + return nil, err + } + + if assertion.HMACSecret == nil { + return nil, errors.New("FIDO2 device doesn't support HMACSecret extension") + } + return assertion.HMACSecret, nil + } + + return nil, errors.New("identity doesn't match any FIDO2 device") +} + +func hmacSecretSalt(nonce []byte) []byte { + // The PRF inputs for age-encryption.org/fido2prf are + // + // "age-encryption.org/fido2prf" || 0x01 || nonce + // + // and + // + // "age-encryption.org/fido2prf" || 0x02 || nonce + // + // The WebAuthn PRF inputs are then hashed into FIDO2 hmac-secret salts. + // + // SHA-256("WebAuthn PRF" || 0x00 || input) + // + h := sha256.New() + h.Write([]byte("WebAuthn PRF")) + h.Write([]byte{0}) + h.Write([]byte(label)) + h.Write([]byte{1}) + h.Write(nonce) + salt := h.Sum(nil) + h.Reset() + h.Write([]byte("WebAuthn PRF")) + h.Write([]byte{0}) + h.Write([]byte(label)) + h.Write([]byte{2}) + h.Write(nonce) + return h.Sum(salt) +} + +func (i *Identity) Unwrap(s []*age.Stanza) ([]byte, error) { + for _, stanza := range s { + if stanza.Type != label { + continue + } + if len(stanza.Args) != 1 { + return nil, errors.New("fido2prf: invalid stanza: expected 1 argument") + } + nonce, err := base64.RawStdEncoding.Strict().DecodeString(stanza.Args[0]) + if err != nil || len(nonce) != 16 { + return nil, errors.New("fido2prf: invalid nonce") + } + secret, err := i.assert(nonce) + if err != nil { + return nil, err + } + key := hkdf.Extract(sha256.New, secret, []byte(label)) + fileKey, err := aeadDecrypt(key, 16, stanza.Body) + if err != nil { + continue + } + return fileKey, nil + } + return nil, age.ErrIncorrectIdentity +} + +func (i *Identity) Wrap(fileKey []byte) ([]*age.Stanza, error) { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + secret, err := i.assert(nonce) + if err != nil { + return nil, err + } + key := hkdf.Extract(sha256.New, secret, []byte(label)) + ciphertext, err := aeadEncrypt(key, fileKey) + if err != nil { + return nil, err + } + return []*age.Stanza{{ + Type: label, + Args: []string{base64.RawStdEncoding.Strict().EncodeToString(nonce)}, + Body: ciphertext, + }}, nil +} + +func NewIdentity(s string, getPIN func() (string, error)) (*Identity, error) { + name, data, err := plugin.ParseIdentity(s) + if err != nil { + return nil, err + } + if name != "fido2prf" { + return nil, errors.New("not a fido2prf identity") + } + return NewIdentityFromData(data, getPIN) +} + +func NewIdentityFromData(data []byte, getPIN func() (string, error)) (*Identity, error) { + var version uint16 + i := &Identity{getPIN: getPIN} + s := ctap2cbor.String(data) + if !s.ReadUint(&version) || version != 1 { + return nil, errors.New("unsupported fido2prf version") + } + if !s.ReadBytes(&i.credentialID) || !s.ReadString(&i.relyingParty) || + !s.ReadArray(&i.transports) || !s.Empty() { + return nil, errors.New("malformed fido2prf identity") + } + return i, nil +} + +func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + if len(ciphertext) != size+aead.Overhead() { + return nil, errors.New("encrypted value has unexpected length") + } + nonce := make([]byte, chacha20poly1305.NonceSize) + return aead.Open(nil, nonce, ciphertext, nil) +} + +func aeadEncrypt(key []byte, plaintext []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + nonce := make([]byte, chacha20poly1305.NonceSize) + return aead.Seal(nil, nonce, plaintext, nil), nil +} diff --git a/fido2prf/internal/ctap2cbor/cbor.go b/fido2prf/internal/ctap2cbor/cbor.go new file mode 100644 index 0000000..5fcfaa8 --- /dev/null +++ b/fido2prf/internal/ctap2cbor/cbor.go @@ -0,0 +1,136 @@ +// Package ctap2cbor implements a tiny subset of CTAP2's subset of CBOR, +// in order to encode and decode WebAuthn identities. +// +// Only major types 0 (unsigned integer), 2 (byte strings), 3 (text strings), +// and 4 (arrays, only containing text strings) are supported. Arguments are +// limited to 16-bit values. +// +// See https://www.imperialviolet.org/tourofwebauthn/tourofwebauthn.html#cbor. +package ctap2cbor + +import "encoding/binary" + +func appendTypeAndArgument(buf []byte, major uint8, arg uint16) []byte { + if arg <= 23 { + return append(buf, (major<<5)|uint8(arg)) + } + if arg <= 0xff { + return append(buf, (major<<5)|24, uint8(arg)) + } + return append(buf, (major<<5)|25, uint8(arg>>8), uint8(arg)) +} + +func AppendUint(buf []byte, arg uint16) []byte { + return appendTypeAndArgument(buf, 0, arg) +} + +func AppendBytes(buf []byte, arg []byte) []byte { + if len(arg) > 0xffff { + panic("ctap2cbor: byte string too long") + } + buf = appendTypeAndArgument(buf, 2, uint16(len(arg))) + return append(buf, arg...) +} + +func AppendString(buf []byte, arg string) []byte { + if len(arg) > 0xffff { + panic("ctap2cbor: string too long") + } + buf = appendTypeAndArgument(buf, 3, uint16(len(arg))) + return append(buf, arg...) +} + +func AppendArray(buf []byte, arg ...string) []byte { + if len(arg) > 0xffff { + panic("ctap2cbor: array too long") + } + buf = appendTypeAndArgument(buf, 4, uint16(len(arg))) + for _, s := range arg { + buf = AppendString(buf, s) + } + return buf +} + +type String []byte + +func (s String) Empty() bool { + return len(s) == 0 +} + +func (s *String) readTypeAndArgument() (major uint8, arg uint16, ok bool) { + if len(*s) < 1 { + return + } + major = (*s)[0] >> 5 + minor := (*s)[0] & 0x1f + switch { + case minor <= 23: + arg = uint16(minor) + *s = (*s)[1:] + case minor == 24: + if len(*s) < 2 { + return + } + arg = uint16((*s)[1]) + *s = (*s)[2:] + case minor == 25: + if len(*s) < 3 { + return + } + arg = binary.BigEndian.Uint16((*s)[1:]) + *s = (*s)[3:] + default: + return + } + ok = true + return +} + +func (s *String) ReadUint(out *uint16) bool { + major, arg, ok := s.readTypeAndArgument() + if !ok || major != 0 { + return false + } + *out = arg + return true +} + +func (s *String) ReadBytes(out *[]byte) bool { + major, arg, ok := s.readTypeAndArgument() + if !ok || major != 2 { + return false + } + if len(*s) < int(arg) { + return false + } + *out = (*s)[:arg] + *s = (*s)[arg:] + return true +} + +func (s *String) ReadString(out *string) bool { + major, arg, ok := s.readTypeAndArgument() + if !ok || major != 3 { + return false + } + if len(*s) < int(arg) { + return false + } + *out = string((*s)[:arg]) + *s = (*s)[arg:] + return true +} + +func (s *String) ReadArray(out *[]string) bool { + major, arg, ok := s.readTypeAndArgument() + if !ok || major != 4 { + return false + } + for i := range arg { + *out = append(*out, "") + if !s.ReadString(&(*out)[i]) { + return false + } + } + return true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a2b809f --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module filippo.io/typage + +go 1.23.5 + +require ( + filippo.io/age v1.2.1-0.20240926110859-2214a556f604 + github.com/keys-pub/go-libfido2 v1.5.4-0.20250104233141-2534349bd685 +) + +require ( + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/crypto v0.24.0 + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..98547e7 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +filippo.io/age v1.2.1-0.20240926110859-2214a556f604 h1:LeljYZXJZFcoXQh8p+C5GGzI2A0M2mxaDileBHw3ch4= +filippo.io/age v1.2.1-0.20240926110859-2214a556f604/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/keys-pub/go-libfido2 v1.5.4-0.20250104233141-2534349bd685 h1:zSJ+NjvdW6SKXv9+EGfbaXYveyamZKw2SE2uJdURCMQ= +github.com/keys-pub/go-libfido2 v1.5.4-0.20250104233141-2534349bd685/go.mod h1:92J9LtSBl0UyUWljElJpTbMMNhC6VeY8dshsu40qjjo= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/lib/cbor.ts b/lib/cbor.ts new file mode 100644 index 0000000..830cc03 --- /dev/null +++ b/lib/cbor.ts @@ -0,0 +1,131 @@ +// This file implements a tiny subset of CTAP2's subset of CBOR, in order to +// encode and decode WebAuthn identities. +// +// Only major types 0 (unsigned integer), 2 (byte strings), 3 (text strings), +// and 4 (arrays, only containing text strings) are supported. Arguments are +// limited to 16-bit values. +// +// See https://www.imperialviolet.org/tourofwebauthn/tourofwebauthn.html#cbor. + +function readTypeAndArgument(b: Uint8Array): [number, number, Uint8Array] { + if (b.length === 0) { + throw Error("cbor: unexpected EOF") + } + const major = b[0] >> 5 + const minor = b[0] & 0x1f + if (minor <= 23) { + return [major, minor, b.subarray(1)] + } + if (minor === 24) { + if (b.length < 2) { + throw Error("cbor: unexpected EOF") + } + return [major, b[1], b.subarray(2)] + } + if (minor === 25) { + if (b.length < 3) { + throw Error("cbor: unexpected EOF") + } + return [major, (b[1] << 8) | b[2], b.subarray(3)] + } + throw Error("cbor: unsupported argument encoding") +} + +export function readUint(b: Uint8Array): [number, Uint8Array] { + const [major, minor, rest] = readTypeAndArgument(b) + if (major !== 0) { + throw Error("cbor: expected unsigned integer") + } + return [minor, rest] +} + +export function readByteString(b: Uint8Array): [Uint8Array, Uint8Array] { + const [major, minor, rest] = readTypeAndArgument(b) + if (major !== 2) { + throw Error("cbor: expected byte string") + } + if (minor > rest.length) { + throw Error("cbor: unexpected EOF") + } + return [rest.subarray(0, minor), rest.subarray(minor)] +} + +export function readTextString(b: Uint8Array): [string, Uint8Array] { + const [major, minor, rest] = readTypeAndArgument(b) + if (major !== 3) { + throw Error("cbor: expected text string") + } + if (minor > rest.length) { + throw Error("cbor: unexpected EOF") + } + return [new TextDecoder().decode(rest.subarray(0, minor)), rest.subarray(minor)] +} + +export function readArray(b: Uint8Array): [string[], Uint8Array] { + const [major, minor, r] = readTypeAndArgument(b) + if (major !== 4) { + throw Error("cbor: expected array") + } + let rest = r + const args = [] + for (let i = 0; i < minor; i++) { + let arg + [arg, rest] = readTextString(rest) + args.push(arg) + } + return [args, rest] +} + +export function encodeUint(n: number): Uint8Array { + if (n <= 23) { + return new Uint8Array([n]) + } + if (n <= 0xff) { + return new Uint8Array([24, n]) + } + if (n <= 0xffff) { + return new Uint8Array([25, n >> 8, n & 0xff]) + } + throw Error("cbor: unsigned integer too large") +} + +export function encodeByteString(b: Uint8Array): Uint8Array { + if (b.length <= 23) { + return new Uint8Array([2 << 5 | b.length, ...b]) + } + if (b.length <= 0xff) { + return new Uint8Array([2 << 5 | 24, b.length, ...b]) + } + if (b.length <= 0xffff) { + return new Uint8Array([2 << 5 | 25, b.length >> 8, b.length & 0xff, ...b]) + } + throw Error("cbor: byte string too long") +} + +export function encodeTextString(s: string): Uint8Array { + const b = new TextEncoder().encode(s) + if (b.length <= 23) { + return new Uint8Array([3 << 5 | b.length, ...b]) + } + if (b.length <= 0xff) { + return new Uint8Array([3 << 5 | 24, b.length, ...b]) + } + if (b.length <= 0xffff) { + return new Uint8Array([3 << 5 | 25, b.length >> 8, b.length & 0xff, ...b]) + } + throw Error("cbor: text string too long") +} + +export function encodeArray(args: string[]): Uint8Array { + const body = args.flatMap(x => [...encodeTextString(x)]) + if (args.length <= 23) { + return new Uint8Array([4 << 5 | args.length, ...body]) + } + if (args.length <= 0xff) { + return new Uint8Array([4 << 5 | 24, args.length, ...body]) + } + if (args.length <= 0xffff) { + return new Uint8Array([4 << 5 | 25, args.length >> 8, args.length & 0xff, ...body]) + } + throw Error("cbor: array too long") +} diff --git a/lib/index.ts b/lib/index.ts index ddc6046..935151a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,6 +8,8 @@ import { decryptSTREAM, encryptSTREAM } from "./stream.js" export * as armor from "./armor.js" +export * as webauthn from "./webauthn.js" + export { Stanza } /** diff --git a/lib/recipients.ts b/lib/recipients.ts index c840067..7115fab 100644 --- a/lib/recipients.ts +++ b/lib/recipients.ts @@ -18,7 +18,7 @@ import { Identity, Recipient } from "./index.js" */ export function generateIdentity(): Promise { const scalar = randomBytes(32) - const identity = bech32.encode("AGE-SECRET-KEY-", bech32.toWords(scalar)).toUpperCase() + const identity = bech32.encodeFromBytes("AGE-SECRET-KEY-", scalar).toUpperCase() return Promise.resolve(identity) } @@ -49,7 +49,7 @@ export async function identityToRecipient(identity: string | CryptoKey): Promise } const recipient = await x25519.scalarMultBase(scalar) - return bech32.encode("age", bech32.toWords(recipient)) + return bech32.encodeFromBytes("age", recipient) } export class X25519Recipient implements Recipient { @@ -188,12 +188,12 @@ export class ScryptIdentity implements Identity { } } -function encryptFileKey(fileKey: Uint8Array, key: Uint8Array): Uint8Array { +export function encryptFileKey(fileKey: Uint8Array, key: Uint8Array): Uint8Array { const nonce = new Uint8Array(12) return chacha20poly1305(key, nonce).encrypt(fileKey) } -function decryptFileKey(body: Uint8Array, key: Uint8Array): Uint8Array | null { +export function decryptFileKey(body: Uint8Array, key: Uint8Array): Uint8Array | null { if (body.length !== 32) { throw Error("invalid stanza") } diff --git a/lib/webauthn.ts b/lib/webauthn.ts new file mode 100644 index 0000000..f09b825 --- /dev/null +++ b/lib/webauthn.ts @@ -0,0 +1,302 @@ +import { bech32, base64nopad } from "@scure/base" +import { randomBytes } from "@noble/hashes/utils" +import { extract } from "@noble/hashes/hkdf" +import { sha256 } from "@noble/hashes/sha256" +import { Identity, Recipient } from "./index.js" +import { Stanza } from "./format.js" +import { decryptFileKey, encryptFileKey } from "./recipients.js" +import * as cbor from "./cbor.js" + +/** + * Options for {@link createCredential}. + */ +export interface CreationOptions { + /** + * The name of the key. This will be shown in various platform UIs. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#name_2 | PublicKeyCredentialCreationOptions.user.name} + */ + keyName: string; + + /** + * The type of credential to create. + * + * If the default `passkey` is used, the credential will be required to be + * discoverable. This means that the user will be able to select it from a + * list of credentials even if {@link Options.identity} is not set. + * + * If `security-key` is used, the `security-key` hint and the `discouraged` + * residentKey option will be passed to the authenticator. The returned + * identity string MUST be passed with {@link Options.identity} to encrypt + * and decrypt files, and CAN'T be regenerated if lost. The UI will prompt + * the user to use a hardware token. The returned identity might also be + * usable with age-plugin-fido2prf outside the browser. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#residentkey | PublicKeyCredentialCreationOptions.authenticatorSelection.residentKey} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#hints | PublicKeyCredentialCreationOptions.hints} + */ + type?: "passkey" | "security-key"; + + /** + * The relying party ID to use for the WebAuthn credential. + * + * This must be the origin's domain (e.g. `app.example.com`), or a parent + * (e.g. `example.com`). Note that credentials are available to subdomains + * of the RP ID, but not to parents, so it's important to choose the right + * RP ID. + * + * @see {@link https://www.imperialviolet.org/tourofwebauthn/tourofwebauthn.html#relying-party-ids | A Tour of WebAuthn § Relying party IDs} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#id_2 | PublicKeyCredentialCreationOptions.rp.id} + */ + rpId?: string; +} + +// We don't actually use the public key, so declare support for all default +// algorithms that might be supported by authenticators. +const defaultAlgorithms: PublicKeyCredentialParameters[] = [ + { type: "public-key", alg: -8 }, // Ed25519 + { type: "public-key", alg: -7 }, // ECDSA with P-256 and SHA-256 + { type: "public-key", alg: -257 }, // RSA PKCS#1 v1.5 with SHA-256 +] + +declare global { + interface PublicKeyCredentialCreationOptions { + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#hints + hints?: ("security-key" | "client-device" | "hybrid")[]; + } + interface PublicKeyCredentialRequestOptions { + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#hints + hints?: ("security-key" | "client-device" | "hybrid")[]; + } +} + +/** + * Creates a new WebAuthn credential which can be used for encryption and + * decryption. + * + * @returns The identity string to use for encryption or decryption. + * + * This string begins with `AGE-PLUGIN-FIDO2PRF-1...` and encodes the credential ID, + * the relying party ID, and the transport hint. + * + * If the credential was created with {@link CreationOptions."type"} set to the + * default `passkey`, this string is mostly a hint to make selecting the + * credential easier. If the credential was created with `security-key`, this + * string is required to encrypt and decrypt files, and can't be regenerated if + * lost. + * + * @see {@link Options.identity} + * @experimental + */ +export async function createCredential(options: CreationOptions): Promise { + const cred = await navigator.credentials.create({ + publicKey: { + rp: { name: "", id: options.rpId }, + user: { + name: options.keyName, + id: randomBytes(8), // avoid overwriting existing keys + displayName: "", + }, + pubKeyCredParams: defaultAlgorithms, + authenticatorSelection: { + requireResidentKey: options.type !== "security-key", + residentKey: options.type !== "security-key" ? "required" : "discouraged", + userVerification: "required", // prf requires UV + }, + hints: options.type === "security-key" ? ["security-key"] : [], + extensions: { prf: {} }, + challenge: new Uint8Array([0]).buffer, // unused without attestation + }, + }) as PublicKeyCredential + if (!cred.getClientExtensionResults().prf?.enabled) { + throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") + } + // Annoyingly, it doesn't seem possible to get the RP ID from the + // credential, so we have to hope we get the default right. + const rpId = options.rpId ?? new URL(window.origin).hostname + return encodeIdentity(cred, rpId) +} + +const prefix = "AGE-PLUGIN-FIDO2PRF-" + +function encodeIdentity(credential: PublicKeyCredential, rpId: string): string { + const res = credential.response as AuthenticatorAttestationResponse + const version = cbor.encodeUint(1) + const credId = cbor.encodeByteString(new Uint8Array(credential.rawId)) + const rp = cbor.encodeTextString(rpId) + const transports = cbor.encodeArray(res.getTransports()) + const identityData = new Uint8Array([...version, ...credId, ...rp, ...transports]) + return bech32.encode(prefix, bech32.toWords(identityData), false).toUpperCase() +} + +function decodeIdentity(identity: string): [Uint8Array, string, string[]] { + const res = bech32.decodeToBytes(identity) + if (!identity.startsWith(prefix + "1")) { + throw Error("invalid identity") + } + const [version, rest1] = cbor.readUint(res.bytes) + if (version !== 1) { + throw Error("unsupported identity version") + } + const [credId, rest2] = cbor.readByteString(rest1) + const [rpId, rest3] = cbor.readTextString(rest2) + const [transports,] = cbor.readArray(rest3) + return [credId, rpId, transports] +} + +/** + * Options for {@link WebAuthnRecipient} and {@link WebAuthnIdentity}. + */ +export interface Options { + /** + * The identity string to use for encryption or decryption. + * + * If set, the file will be encrypted or decrypted with this specific + * credential. Otherwise, the user will be prompted to select a discoverable + * credential from those available for the RP (which might include login + * credentials, which won't work). + * + * @see {@link createCredential} + */ + identity?: string; + + /** + * The relying party ID for discoverable credentials. Ignored if + * {@link identity} is set, as the RP ID is parsed from the identity. + * + * @see {@link CreationOptions.rpId} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#rpid | PublicKeyCredentialRequestOptions.rpId} + */ + rpId?: string; +} + +const label = "age-encryption.org/fido2prf" + +class WebAuthnInternal { + private credId: Uint8Array | undefined + private transports: string[] | undefined + private rpId: string | undefined + + constructor(options?: Options) { + if (options?.identity) { + const [credId, rpId, transports] = decodeIdentity(options.identity) + this.credId = credId + this.transports = transports + this.rpId = rpId + } else { + this.rpId = options?.rpId + } + } + + protected async getCredential(nonce: Uint8Array): Promise { + const assertion = await navigator.credentials.get({ + publicKey: { + allowCredentials: this.credId ? [{ + id: this.credId, + transports: this.transports as AuthenticatorTransport[], + type: "public-key" + }] : [], + challenge: randomBytes(16), + extensions: { prf: { eval: prfInputs(nonce) } }, + userVerification: "required", // prf requires UV + rpId: this.rpId, + }, + }) as PublicKeyCredential + const results = assertion.getClientExtensionResults().prf?.results + if (results === undefined) { + throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") + } + return results + } +} + +// For the WebAuthnRecipient and WebAuthnIdentity TSDoc links. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { type Encrypter, type Decrypter } from "./index.js" + +/** + * A {@link Recipient} that symmetrically encrypts file keys using a WebAuthn + * credential, such as a passkey or a security key. + * + * The credential needs to already exist, and support the PRF extension. + * Usually, it would have been created with {@link createCredential}. + * + * @see {@link Encrypter.addRecipient} + * @experimental + */ +export class WebAuthnRecipient extends WebAuthnInternal implements Recipient { + /** + * Implements {@link Recipient.wrapFileKey}. + */ + async wrapFileKey(fileKey: Uint8Array): Promise { + const nonce = randomBytes(16) + const results = await this.getCredential(nonce) + const key = deriveKey(results) + return [new Stanza([label, base64nopad.encode(nonce)], encryptFileKey(fileKey, key))] + } +} + +/** + * An {@link Identity} that symmetrically decrypts file keys using a WebAuthn + * credential, such as a passkey or a security key. + * + * The credential needs to already exist, and support the PRF extension. + * Usually, it would have been created with {@link createCredential}. + * + * @see {@link Decrypter.addIdentity} + * @experimental + */ +export class WebAuthnIdentity extends WebAuthnInternal implements Identity { + /** + * Implements {@link Identity.unwrapFileKey}. + */ + async unwrapFileKey(stanzas: Stanza[]): Promise { + for (const s of stanzas) { + if (s.args.length < 1 || s.args[0] !== label) { + continue + } + if (s.args.length !== 2) { + throw Error("invalid prf stanza") + } + const nonce = base64nopad.decode(s.args[1]) + if (nonce.length !== 16) { + throw Error("invalid prf stanza") + } + + const results = await this.getCredential(nonce) + const key = deriveKey(results) + const fileKey = decryptFileKey(s.body, key) + if (fileKey !== null) return fileKey + } + return null + } +} + +// We use both first and second to prevent an attacker from decrypting two files +// at once with a single user presence check. + +function prfInputs(nonce: Uint8Array): AuthenticationExtensionsPRFValues { + const prefix = new TextEncoder().encode(label) + + const first = new Uint8Array(prefix.length + nonce.length + 1) + first.set(prefix, 0) + first[prefix.length] = 0x01 + first.set(nonce, prefix.length + 1) + + const second = new Uint8Array(prefix.length + nonce.length + 1) + second.set(prefix, 0) + second[prefix.length] = 0x02 + second.set(nonce, prefix.length + 1) + + return { first, second } +} + +function deriveKey(results: AuthenticationExtensionsPRFValues): Uint8Array { + if (results.second === undefined) { + throw Error("Missing second PRF result") + } + const prf = new Uint8Array(results.first.byteLength + results.second.byteLength) + prf.set(new Uint8Array(results.first as ArrayBuffer), 0) + prf.set(new Uint8Array(results.second as ArrayBuffer), results.first.byteLength) + return extract(sha256, prf, label) +} diff --git a/package.json b/package.json index 9cee360..d617e09 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "examples:esbuild": "cd tests/examples && npm update && npm run test:esbuild", "bench": "vitest bench --run", "lint": "eslint .", + "serve": "esbuild wwwdev/index.ts --bundle --outdir=wwwdev/js --servedir=wwwdev --sourcemap", "build": "tsc -p tsconfig.build.json", "docs": "typedoc", "prepublishOnly": "npm run build" diff --git a/typedoc.json b/typedoc.json index 413ea6b..eea9d81 100644 --- a/typedoc.json +++ b/typedoc.json @@ -5,5 +5,6 @@ "out": "docs", "readme": "none", "tsconfig": "tsconfig.build.json", + "excludeProtected": true, "validation": true } diff --git a/wwwdev/index.html b/wwwdev/index.html new file mode 100644 index 0000000..c739c40 --- /dev/null +++ b/wwwdev/index.html @@ -0,0 +1,28 @@ + + + + + typage dev + + + + +

typage dev

+

Open the JavaScript console to access age.

+ + + diff --git a/wwwdev/index.ts b/wwwdev/index.ts new file mode 100644 index 0000000..9d06542 --- /dev/null +++ b/wwwdev/index.ts @@ -0,0 +1,4 @@ +// Not using "age-encryption" to load the TS files directly. +import * as age from "../lib/index.js" +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any +(globalThis as any).age = age