public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE
@ 2021-11-09 11:26 Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 1/6] import basic skeleton Wolfgang Bumiller
                   ` (32 more replies)
  0 siblings, 33 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

This is a bigger TFA upgrade for PVE.

This also contains the code for a new rust repository which will merge
pve-rs and pmg-rs into 1 git repository.
(git clone currently only available internally as my
`proxmox-perl-rs.git` repository)

Most of the heavy lifting is now performed by the rust library.
Note that the idea is that PVE and PBS can share this code directly, but
for now the to-be-shared part is directly included here and will become
its own crate after the initial PVE integration, as PBS will require a
few changes (since the code originally hardcoded pbs types/paths/files...)

On the perl side this contains:

pve-common:
  * A small change to the ticket code to url-escape colons in
    the ticket data.
    We also do this in pbs and since we only had usernames or base64
    encoded tfa data in there this should be fine, and we want to store
    JSON data directly there to be compatible with PBS.
pve-cluster:
  * Webauthn configuration in datacenter.cfg.
    While PBS keeps this in the tfa json file, we already have the U2F
    config in datacenter.cfg in PVE, so putting it into datacenter.cfg
    seemed more consistent.
proxmox-widget-toolkit:
  * This series basically copies PBS' TFA code
pve-manager:
  * Update the login code to use the new workflow.
  * Add the new TFA panel.
  * Change the user TFA button to simply navigate to the new TFA panel
    instead of popping up the old window.
pve-access-control:
  * Switch to the rust-parse for the tfa config.
  * Update the login code to be more in line with PBS.
  * Add the TFA API we have in PBS via the rust module.

  @Thomas: This still contains a fixme about verifying the
  pve-access-control versions within the cluster...




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH proxmox-perl-rs 1/6] import basic skeleton
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 2/6] import pve-rs Wolfgang Bumiller
                   ` (31 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 .cargo/config |  5 +++++
 .gitignore    |  7 +++++++
 Cargo.toml    |  2 ++
 Makefile      | 43 +++++++++++++++++++++++++++++++++++++++++++
 rustfmt.toml  |  1 +
 5 files changed, 58 insertions(+)
 create mode 100644 .cargo/config
 create mode 100644 .gitignore
 create mode 100644 Cargo.toml
 create mode 100644 Makefile
 create mode 100644 rustfmt.toml

diff --git a/.cargo/config b/.cargo/config
new file mode 100644
index 0000000..3b5b6e4
--- /dev/null
+++ b/.cargo/config
@@ -0,0 +1,5 @@
+[source]
+[source.debian-packages]
+directory = "/usr/share/cargo/registry"
+[source.crates-io]
+replace-with = "debian-packages"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c74dd6c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/target
+/*/target
+/build
+Cargo.lock
+/test.pl
+/PVE
+/PMG
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..e2c6ef8
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,2 @@
+[workspace]
+exclude = [ "build", "perl-*" ]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b6cb8bb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+CARGO ?= cargo
+
+define to_upper
+$(shell echo "$(1)" | tr '[:lower:]' '[:upper:]')
+endef
+
+ifeq ($(BUILD_MODE), release)
+CARGO_BUILD_ARGS += --release
+endif
+
+.PHONY: all
+all:
+ifeq ($(BUILD_TARGET), pve)
+	$(MAKE) pve
+else ifeq ($(BUILD_TARGET), pmg)
+	$(MAKE) pve
+else
+	@echo "Run 'make pve' or 'make pmg'"
+endif
+
+.PHONY: pve pmg
+pve pmg:
+	@PERLMOD_PRODUCT=$(call to_upper,$@) \
+	  $(CARGO) $(CARGO_BUILD_ARGS) build -p $@-rs
+
+build:
+	mkdir build
+	echo system >build/rust-toolchain
+	cp -a ./perl-* ./build/
+	cp -a ./pve-rs ./pmg-rs ./build
+
+pve-deb: build
+	cd ./build/pve-rs && dpkg-buildpackage -b -uc -us
+	touch $@
+
+pmg-deb: build
+	cd ./build/pmg-rs && dpkg-buildpackage -b -uc -us
+	touch $@
+
+.PHONY: clean
+clean:
+	cargo clean
+	rm -rf ./build ./PVE ./PMG ./pve-deb ./pmg-deb
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..32a9786
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1 @@
+edition = "2018"
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH proxmox-perl-rs 2/6] import pve-rs
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 1/6] import basic skeleton Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 3/6] move apt to /perl-apt, use PERLMOD_PRODUCT env var Wolfgang Bumiller
                   ` (30 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 Cargo.toml                     |   3 +
 pve-rs/Cargo.toml              |  40 ++++++++
 pve-rs/Makefile                |  76 ++++++++++++++++
 pve-rs/debian/changelog        |  55 +++++++++++
 pve-rs/debian/compat           |   1 +
 pve-rs/debian/control          |  41 +++++++++
 pve-rs/debian/copyright        |  16 ++++
 pve-rs/debian/debcargo.toml    |   8 ++
 pve-rs/debian/rules            |   8 ++
 pve-rs/debian/source/format    |   1 +
 pve-rs/debian/triggers         |   1 +
 pve-rs/src/apt/mod.rs          |   1 +
 pve-rs/src/apt/repositories.rs | 162 +++++++++++++++++++++++++++++++++
 pve-rs/src/lib.rs              |   2 +
 pve-rs/src/openid/mod.rs       |  88 ++++++++++++++++++
 15 files changed, 503 insertions(+)
 create mode 100644 pve-rs/Cargo.toml
 create mode 100644 pve-rs/Makefile
 create mode 100644 pve-rs/debian/changelog
 create mode 100644 pve-rs/debian/compat
 create mode 100644 pve-rs/debian/control
 create mode 100644 pve-rs/debian/copyright
 create mode 100644 pve-rs/debian/debcargo.toml
 create mode 100755 pve-rs/debian/rules
 create mode 100644 pve-rs/debian/source/format
 create mode 100644 pve-rs/debian/triggers
 create mode 100644 pve-rs/src/apt/mod.rs
 create mode 100644 pve-rs/src/apt/repositories.rs
 create mode 100644 pve-rs/src/lib.rs
 create mode 100644 pve-rs/src/openid/mod.rs

diff --git a/Cargo.toml b/Cargo.toml
index e2c6ef8..c413d77 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,2 +1,5 @@
 [workspace]
 exclude = [ "build", "perl-*" ]
+members = [
+    "pve-rs",
+]
diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
new file mode 100644
index 0000000..1c00edd
--- /dev/null
+++ b/pve-rs/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "pve-rs"
+version = "0.3.0"
+authors = ["Proxmox Support Team <support@proxmox.com>"]
+edition = "2018"
+license = "AGPL-3"
+description = "PVE parts which have been ported to Rust"
+homepage = "https://www.proxmox.com"
+
+exclude = [
+    "debian",
+]
+
+[lib]
+crate-type = [ "cdylib" ]
+
+[dependencies]
+anyhow = "1.0"
+base32 = "0.4"
+base64 = "0.12"
+hex = "0.4"
+libc = "0.2"
+nix = "0.19"
+openssl = "0.10"
+serde = "1.0"
+serde_bytes = "0.11"
+serde_json = "1.0"
+
+perlmod = { version = "0.8.1", features = [ "exporter" ] }
+
+proxmox-apt = "0.8"
+proxmox-openid = "0.8"
+
+#proxmox-tfa-api = { path = "../proxmox-tfa-api", version = "0.1" }
+
+# Dependencies purely in proxmox-tfa-api:
+webauthn-rs = "0.2.5"
+proxmox-time = "1"
+proxmox-uuid = "1"
+proxmox-tfa = { version = "1.2", features = ["u2f"] }
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
new file mode 100644
index 0000000..c912d9d
--- /dev/null
+++ b/pve-rs/Makefile
@@ -0,0 +1,76 @@
+include /usr/share/dpkg/default.mk
+
+PACKAGE=libpve-rs-perl
+export PERLMOD_PRODUCT=PVE
+
+ARCH:=$(shell dpkg-architecture -qDEB_BUILD_ARCH)
+export GITVERSION:=$(shell git rev-parse HEAD)
+
+PERL_INSTALLVENDORARCH != perl -MConfig -e 'print $$Config{installvendorarch};'
+PERL_INSTALLVENDORLIB != perl -MConfig -e 'print $$Config{installvendorlib};'
+
+MAIN_DEB=${PACKAGE}_${DEB_VERSION}_${ARCH}.deb
+DBGSYM_DEB=${PACKAGE}-dbgsym_${DEB_VERSION}_${ARCH}.deb
+DEBS=$(MAIN_DEB) $(DBGSYM_DEB)
+
+DESTDIR=
+
+PM_DIRS := \
+	PVE/RS/APT
+
+PM_FILES := \
+	PVE/RS/APT/Repositories.pm \
+	PVE/RS/OpenId.pm \
+	PVE/RS/TFA.pm
+
+ifeq ($(BUILD_MODE), release)
+CARGO_BUILD_ARGS += --release
+endif
+
+all:
+ifneq ($(BUILD_MODE), skip)
+	cargo build $(CARGO_BUILD_ARGS)
+endif
+
+# always re-create this dir
+# but also copy the local target/ and PVE/ dirs as a build-cache
+.PHONY: build
+build:
+	rm -rf build
+	cargo build --release
+	rsync -a debian Makefile Cargo.toml Cargo.lock src target PVE build/
+
+.PHONY: install
+install: target/release/libpve_rs.so
+	install -d -m755 $(DESTDIR)$(PERL_INSTALLVENDORARCH)/auto
+	install -m644 target/release/libpve_rs.so $(DESTDIR)$(PERL_INSTALLVENDORARCH)/auto/libpve_rs.so
+	install -d -m755 $(DESTDIR)$(PERL_INSTALLVENDORLIB)/PVE/RS
+	for i in $(PM_DIRS); do \
+	  install -d -m755 $(DESTDIR)$(PERL_INSTALLVENDORLIB)/$$i; \
+	done
+	for i in $(PM_FILES); do \
+	  install -m644 $$i $(DESTDIR)$(PERL_INSTALLVENDORLIB)/$$i; \
+	done
+
+.PHONY: deb
+deb: $(MAIN_DEB)
+$(MAIN_DEB): build
+	cd build; dpkg-buildpackage -b -us -uc --no-pre-clean
+	lintian $(DEBS)
+
+distclean: clean
+
+clean:
+	cargo clean
+	rm -rf *.deb *.dsc *.tar.gz *.buildinfo *.changes Cargo.lock build
+	find . -name '*~' -exec rm {} ';'
+
+.PHONY: dinstall
+dinstall: ${DEBS}
+	dpkg -i ${DEBS}
+
+.PHONY: upload
+upload: ${DEBS}
+	# check if working directory is clean
+	git diff --exit-code --stat && git diff --exit-code --stat --staged
+	tar cf - ${DEBS} | ssh -X repoman@repo.proxmox.com upload --product pve --dist bullseye
diff --git a/pve-rs/debian/changelog b/pve-rs/debian/changelog
new file mode 100644
index 0000000..62060bf
--- /dev/null
+++ b/pve-rs/debian/changelog
@@ -0,0 +1,55 @@
+libpve-rs-perl (0.3.0) UNRELEASED; urgency=medium
+
+  * add TFA api
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 20 Oct 2021 10:11:47 +0200
+
+libpve-rs-perl (0.2.3) bullseye; urgency=medium
+
+  * use newer dependencies for apt to improve repo+suite handling
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 29 Jul 2021 18:13:07 +0200
+
+libpve-rs-perl (0.2.2) bullseye; urgency=medium
+
+  * apt: avoid overwriting files that could not be parsed
+
+  * apt: check if repository is already configured before adding
+
+ -- Proxmox Support Team <support@proxmox.com>  Fri, 02 Jul 2021 13:06:42 +0200
+
+libpve-rs-perl (0.2.1) bullseye; urgency=medium
+
+  * depend on proxmox-apt 0.4.0
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 01 Jul 2021 18:37:20 +0200
+
+libpve-rs-perl (0.2.0) bullseye; urgency=medium
+
+  * add bindings for proxmox-apt
+
+  * depend on proxmox-openid 0.6.0
+
+  * move to native version format
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 30 Jun 2021 20:56:19 +0200
+
+libpve-rs-perl (0.1.2-1) unstable; urgency=medium
+
+  * depend on proxmox-openid 0.5.0
+
+  * set proxmox "default-features = false"
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 23 Jun 2021 11:34:34 +0200
+
+libpve-rs-perl (0.1.1-1) unstable; urgency=medium
+
+  * depend on perlmod 0.5.1
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 23 Jun 2021 11:09:31 +0200
+
+libpve-rs-perl (0.1.0-1) unstable; urgency=medium
+
+  * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 27 May 2021 10:41:30 +0200
diff --git a/pve-rs/debian/compat b/pve-rs/debian/compat
new file mode 100644
index 0000000..f599e28
--- /dev/null
+++ b/pve-rs/debian/compat
@@ -0,0 +1 @@
+10
diff --git a/pve-rs/debian/control b/pve-rs/debian/control
new file mode 100644
index 0000000..18a99b2
--- /dev/null
+++ b/pve-rs/debian/control
@@ -0,0 +1,41 @@
+Source: libpve-rs-perl
+Section: perl
+Priority: optional
+Build-Depends: debhelper (>= 12),
+ dh-cargo (>= 24),
+ cargo:native <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-base32-0.4+default-dev <!nocheck>,
+ librust-base64-0.12+default-dev <!nocheck>,
+ librust-hex-0.4+default-dev <!nocheck>,
+ librust-libc-0.2+default-dev <!nocheck>,
+ librust-nix-0.19+default-dev <!nocheck>,
+ librust-openssl-0.10+default-dev <!nocheck>,
+ librust-perlmod-0.8+default-dev (>= 0.7.1-~~) <!nocheck>,
+ librust-perlmod-0.8+exporter-dev (>= 0.7.1-~~) <!nocheck>,
+ librust-proxmox-apt-0.8+default-dev <!nocheck>,
+ librust-proxmox-openid-0.8+default-dev <!nocheck>,
+ librust-proxmox-tfa-1+default-dev <!nocheck>,
+ librust-proxmox-tfa-1+u2f-dev <!nocheck>,
+ librust-proxmox-time-1+default-dev <!nocheck>,
+ librust-proxmox-uuid-1+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-json-1+default-dev <!nocheck>,
+ librust-webauthn-rs-0.2+default-dev (>= 0.2.5-~~) <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.5.1
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+Homepage: https://www.proxmox.com
+Rules-Requires-Root: no
+
+Package: libpve-rs-perl
+Architecture: any
+Depends:
+ ${misc:Depends},
+ ${shlibs:Depends},
+Description: PVE parts which have been ported to Rust - Rust source code
+ This package contains the source for the Rust pve-rs crate, packaged by
+ debcargo for use with cargo and dh-cargo.
diff --git a/pve-rs/debian/copyright b/pve-rs/debian/copyright
new file mode 100644
index 0000000..5661ef6
--- /dev/null
+++ b/pve-rs/debian/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2021 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/pve-rs/debian/debcargo.toml b/pve-rs/debian/debcargo.toml
new file mode 100644
index 0000000..e642fe2
--- /dev/null
+++ b/pve-rs/debian/debcargo.toml
@@ -0,0 +1,8 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+section = "perl"
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
diff --git a/pve-rs/debian/rules b/pve-rs/debian/rules
new file mode 100755
index 0000000..2b39115
--- /dev/null
+++ b/pve-rs/debian/rules
@@ -0,0 +1,8 @@
+#!/usr/bin/make -f
+
+#export DH_VERBOSE=1
+export BUILD_MODE=release
+export RUSTFLAGS=-C prefer-dynamic
+
+%:
+	dh $@
diff --git a/pve-rs/debian/source/format b/pve-rs/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/pve-rs/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/pve-rs/debian/triggers b/pve-rs/debian/triggers
new file mode 100644
index 0000000..59dd688
--- /dev/null
+++ b/pve-rs/debian/triggers
@@ -0,0 +1 @@
+activate-noawait pve-api-updates
diff --git a/pve-rs/src/apt/mod.rs b/pve-rs/src/apt/mod.rs
new file mode 100644
index 0000000..574c1a7
--- /dev/null
+++ b/pve-rs/src/apt/mod.rs
@@ -0,0 +1 @@
+mod repositories;
diff --git a/pve-rs/src/apt/repositories.rs b/pve-rs/src/apt/repositories.rs
new file mode 100644
index 0000000..2d5e1da
--- /dev/null
+++ b/pve-rs/src/apt/repositories.rs
@@ -0,0 +1,162 @@
+#[perlmod::package(name = "PVE::RS::APT::Repositories", lib = "pve_rs")]
+mod export {
+    use std::convert::TryInto;
+
+    use anyhow::{bail, Error};
+    use serde::{Deserialize, Serialize};
+
+    use proxmox_apt::repositories::{
+        APTRepositoryFile, APTRepositoryFileError, APTRepositoryHandle, APTRepositoryInfo,
+        APTStandardRepository,
+    };
+
+    #[derive(Deserialize, Serialize)]
+    #[serde(rename_all = "kebab-case")]
+    /// Result for the repositories() function
+    pub struct RepositoriesResult {
+        /// Successfully parsed files.
+        pub files: Vec<APTRepositoryFile>,
+
+        /// Errors for files that could not be parsed or read.
+        pub errors: Vec<APTRepositoryFileError>,
+
+        /// Common digest for successfully parsed files.
+        pub digest: String,
+
+        /// Additional information/warnings about repositories.
+        pub infos: Vec<APTRepositoryInfo>,
+
+        /// Standard repositories and their configuration status.
+        pub standard_repos: Vec<APTStandardRepository>,
+    }
+
+    #[derive(Deserialize, Serialize)]
+    #[serde(rename_all = "kebab-case")]
+    /// For changing an existing repository.
+    pub struct ChangeProperties {
+        /// Whether the repository should be enabled or not.
+        pub enabled: Option<bool>,
+    }
+
+    /// Get information about configured and standard repositories.
+    #[export]
+    pub fn repositories() -> Result<RepositoriesResult, Error> {
+        let (files, errors, digest) = proxmox_apt::repositories::repositories()?;
+        let digest = hex::encode(&digest);
+
+        let suite = proxmox_apt::repositories::get_current_release_codename()?;
+
+        let infos = proxmox_apt::repositories::check_repositories(&files, suite);
+        let standard_repos = proxmox_apt::repositories::standard_repositories(&files, "pve", suite);
+
+        Ok(RepositoriesResult {
+            files,
+            errors,
+            digest,
+            infos,
+            standard_repos,
+        })
+    }
+
+    /// Add the repository identified by the `handle`.
+    /// If the repository is already configured, it will be set to enabled.
+    ///
+    /// The `digest` parameter asserts that the configuration has not been modified.
+    #[export]
+    pub fn add_repository(handle: &str, digest: Option<&str>) -> Result<(), Error> {
+        let (mut files, errors, current_digest) = proxmox_apt::repositories::repositories()?;
+
+        let handle: APTRepositoryHandle = handle.try_into()?;
+        let suite = proxmox_apt::repositories::get_current_release_codename()?;
+
+        if let Some(digest) = digest {
+            let expected_digest = hex::decode(digest)?;
+            if expected_digest != current_digest {
+                bail!("detected modified configuration - file changed by other user? Try again.");
+            }
+        }
+
+        // check if it's already configured first
+        for file in files.iter_mut() {
+            for repo in file.repositories.iter_mut() {
+                if repo.is_referenced_repository(handle, "pve", &suite.to_string()) {
+                    if repo.enabled {
+                        return Ok(());
+                    }
+
+                    repo.set_enabled(true);
+                    file.write()?;
+
+                    return Ok(());
+                }
+            }
+        }
+
+        let (repo, path) = proxmox_apt::repositories::get_standard_repository(handle, "pve", suite);
+
+        if let Some(error) = errors.iter().find(|error| error.path == path) {
+            bail!(
+                "unable to parse existing file {} - {}",
+                error.path,
+                error.error,
+            );
+        }
+
+        if let Some(file) = files.iter_mut().find(|file| file.path == path) {
+            file.repositories.push(repo);
+
+            file.write()?;
+        } else {
+            let mut file = match APTRepositoryFile::new(&path)? {
+                Some(file) => file,
+                None => bail!("invalid path - {}", path),
+            };
+
+            file.repositories.push(repo);
+
+            file.write()?;
+        }
+
+        Ok(())
+    }
+
+    /// Change the properties of the specified repository.
+    ///
+    /// The `digest` parameter asserts that the configuration has not been modified.
+    #[export]
+    pub fn change_repository(
+        path: &str,
+        index: usize,
+        options: ChangeProperties,
+        digest: Option<&str>,
+    ) -> Result<(), Error> {
+        let (mut files, errors, current_digest) = proxmox_apt::repositories::repositories()?;
+
+        if let Some(digest) = digest {
+            let expected_digest = hex::decode(digest)?;
+            if expected_digest != current_digest {
+                bail!("detected modified configuration - file changed by other user? Try again.");
+            }
+        }
+
+        if let Some(error) = errors.iter().find(|error| error.path == path) {
+            bail!("unable to parse file {} - {}", error.path, error.error);
+        }
+
+        if let Some(file) = files.iter_mut().find(|file| file.path == path) {
+            if let Some(repo) = file.repositories.get_mut(index) {
+                if let Some(enabled) = options.enabled {
+                    repo.set_enabled(enabled);
+                }
+
+                file.write()?;
+            } else {
+                bail!("invalid index - {}", index);
+            }
+        } else {
+            bail!("invalid path - {}", path);
+        }
+
+        Ok(())
+    }
+}
diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs
new file mode 100644
index 0000000..cad331d
--- /dev/null
+++ b/pve-rs/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod apt;
+pub mod openid;
diff --git a/pve-rs/src/openid/mod.rs b/pve-rs/src/openid/mod.rs
new file mode 100644
index 0000000..febe927
--- /dev/null
+++ b/pve-rs/src/openid/mod.rs
@@ -0,0 +1,88 @@
+#[perlmod::package(name = "PVE::RS::OpenId", lib = "pve_rs")]
+mod export {
+    use std::sync::Mutex;
+    use std::convert::TryFrom;
+
+    use anyhow::Error;
+
+    use perlmod::{to_value, Value};
+
+    use proxmox_openid::{OpenIdConfig, OpenIdAuthenticator, PrivateAuthState};
+
+    const CLASSNAME: &str = "PVE::RS::OpenId";
+
+    /// An OpenIdAuthenticator client instance.
+    pub struct OpenId {
+        inner: Mutex<OpenIdAuthenticator>,
+    }
+
+    impl<'a> TryFrom<&'a Value> for &'a OpenId {
+        type Error = Error;
+
+        fn try_from(value: &'a Value) -> Result<&'a OpenId, Error> {
+            Ok(unsafe { value.from_blessed_box(CLASSNAME)? })
+        }
+    }
+
+    fn bless(class: Value, mut ptr: Box<OpenId>) -> Result<Value, Error> {
+        let value = Value::new_pointer::<OpenId>(&mut *ptr);
+        let value = Value::new_ref(&value);
+        let this = value.bless_sv(&class)?;
+        let _perl = Box::leak(ptr);
+        Ok(this)
+    }
+
+    #[export(name = "DESTROY")]
+    fn destroy(#[raw] this: Value) {
+        perlmod::destructor!(this, OpenId: CLASSNAME);
+    }
+
+    /// Create a new OpenId client instance
+    #[export(raw_return)]
+    pub fn discover(
+        #[raw] class: Value,
+        config: OpenIdConfig,
+        redirect_url: &str,
+    ) -> Result<Value, Error> {
+
+        let open_id = OpenIdAuthenticator::discover(&config, redirect_url)?;
+        bless(
+            class,
+            Box::new(OpenId {
+                inner: Mutex::new(open_id),
+            }),
+        )
+    }
+
+    #[export]
+    pub fn authorize_url(
+        #[try_from_ref] this: &OpenId,
+        state_dir: &str,
+        realm: &str,
+    ) -> Result<String, Error> {
+
+        let open_id = this.inner.lock().unwrap();
+        open_id.authorize_url(state_dir, realm)
+    }
+
+    #[export]
+    pub fn verify_public_auth_state(
+        state_dir: &str,
+        state: &str,
+    )  -> Result<(String, PrivateAuthState), Error> {
+        OpenIdAuthenticator::verify_public_auth_state(state_dir, state)
+    }
+
+    #[export(raw_return)]
+    pub fn verify_authorization_code(
+       #[try_from_ref] this: &OpenId,
+        code: &str,
+        private_auth_state: PrivateAuthState,
+    ) -> Result<Value, Error> {
+
+        let open_id = this.inner.lock().unwrap();
+        let claims = open_id.verify_authorization_code(code, &private_auth_state)?;
+
+        Ok(to_value(&claims)?)
+    }
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH proxmox-perl-rs 3/6] move apt to /perl-apt, use PERLMOD_PRODUCT env var
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 1/6] import basic skeleton Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 2/6] import pve-rs Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api Wolfgang Bumiller
                   ` (29 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 {pve-rs/src/apt => perl-apt}/mod.rs          | 0
 {pve-rs/src/apt => perl-apt}/repositories.rs | 2 +-
 pve-rs/src/lib.rs                            | 4 ++++
 pve-rs/src/openid/mod.rs                     | 2 +-
 4 files changed, 6 insertions(+), 2 deletions(-)
 rename {pve-rs/src/apt => perl-apt}/mod.rs (100%)
 rename {pve-rs/src/apt => perl-apt}/repositories.rs (98%)

diff --git a/pve-rs/src/apt/mod.rs b/perl-apt/mod.rs
similarity index 100%
rename from pve-rs/src/apt/mod.rs
rename to perl-apt/mod.rs
diff --git a/pve-rs/src/apt/repositories.rs b/perl-apt/repositories.rs
similarity index 98%
rename from pve-rs/src/apt/repositories.rs
rename to perl-apt/repositories.rs
index 2d5e1da..04fa082 100644
--- a/pve-rs/src/apt/repositories.rs
+++ b/perl-apt/repositories.rs
@@ -1,4 +1,4 @@
-#[perlmod::package(name = "PVE::RS::APT::Repositories", lib = "pve_rs")]
+#[perlmod::package(name = "${PERLMOD_PRODUCT}::RS::APT::Repositories")]
 mod export {
     use std::convert::TryInto;
 
diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs
index cad331d..15793ef 100644
--- a/pve-rs/src/lib.rs
+++ b/pve-rs/src/lib.rs
@@ -1,2 +1,6 @@
+//! Rust library for the Proxmox VE code base.
+
+#[path = "../../perl-apt/mod.rs"]
 pub mod apt;
+
 pub mod openid;
diff --git a/pve-rs/src/openid/mod.rs b/pve-rs/src/openid/mod.rs
index febe927..47eaee3 100644
--- a/pve-rs/src/openid/mod.rs
+++ b/pve-rs/src/openid/mod.rs
@@ -1,4 +1,4 @@
-#[perlmod::package(name = "PVE::RS::OpenId", lib = "pve_rs")]
+#[perlmod::package(name = "${PERLMOD_PRODUCT}::RS::OpenId")]
 mod export {
     use std::sync::Mutex;
     use std::convert::TryFrom;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (2 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 3/6] move apt to /perl-apt, use PERLMOD_PRODUCT env var Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 5/6] build fix: pmg-rs is not here yet Wolfgang Bumiller
                   ` (28 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

This consists of two parts:

1) A proxmox_tfa_api module which temporarily lives here but
   will become its own crate.

   Most of this is a copy from ' src/config/tfa.rs with some
   compatibility changes:
   * The #[api] macro is guarded by a feature flag, since we
     cannot use it for PVE.
   * The Userid type is replaced by &str since we don't have
     Userid in PVE either.
   * The file locking/reading is removed, this will stay in
     the corresponding product code, and the main entry
     point is now the TfaConfig object.
   * Access to the runtime active challenges in /run is
     provided via a trait implementation since PVE and PBS
     will use different paths for this.
   Essentially anything pbs-specific was removed and the
   code split into a few submodules (one per tfa type
   basically).

2) The tfa module in pve-rs, which contains:
   * The parser for the OLD /etc/pve/priv/tfa.cfg
   * The parser for the NEW /etc/pve/priv/tfa.cfg
   * These create a blessed PVE::RS::TFA instance which:
     - Wraps access to the TfaConfig rust object.
     - Has methods all the TFA API call implementations
       These are copied from PBS' src/api2/access/tfa.rs,
       and pbs specific code removed.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 pve-rs/src/lib.rs                             |    1 +
 pve-rs/src/tfa/mod.rs                         |  965 ++++++++++++++++
 pve-rs/src/tfa/proxmox_tfa_api/api.rs         |  487 ++++++++
 pve-rs/src/tfa/proxmox_tfa_api/mod.rs         | 1003 +++++++++++++++++
 pve-rs/src/tfa/proxmox_tfa_api/recovery.rs    |  153 +++
 pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs |  111 ++
 pve-rs/src/tfa/proxmox_tfa_api/u2f.rs         |   89 ++
 pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs    |  118 ++
 8 files changed, 2927 insertions(+)
 create mode 100644 pve-rs/src/tfa/mod.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/api.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/mod.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs

diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs
index 15793ef..594a96f 100644
--- a/pve-rs/src/lib.rs
+++ b/pve-rs/src/lib.rs
@@ -4,3 +4,4 @@
 pub mod apt;
 
 pub mod openid;
+pub mod tfa;
diff --git a/pve-rs/src/tfa/mod.rs b/pve-rs/src/tfa/mod.rs
new file mode 100644
index 0000000..56e63f2
--- /dev/null
+++ b/pve-rs/src/tfa/mod.rs
@@ -0,0 +1,965 @@
+//! This implements the `tfa.cfg` parser & TFA API calls for PVE.
+//!
+//! The exported `PVE::RS::TFA` perl package provides access to rust's `TfaConfig` as well as
+//! transparently providing the old style TFA config so that as long as users only have a single
+//! TFA entry, the old authentication API still works.
+//!
+//! NOTE: In PVE the tfa config is behind `PVE::Cluster`'s `ccache` and therefore must be clonable
+//! via `Storable::dclone`, so we implement the storable hooks `STORABLE_freeze` and
+//! `STORABLE_attach`. Note that we only allow *cloning*, not freeze/thaw.
+
+use std::convert::TryFrom;
+use std::fs::File;
+use std::io::{self, Read};
+use std::os::unix::fs::OpenOptionsExt;
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::path::{Path, PathBuf};
+
+use anyhow::{bail, format_err, Error};
+use nix::errno::Errno;
+use nix::sys::stat::Mode;
+use serde_json::Value as JsonValue;
+
+mod proxmox_tfa_api;
+pub(self) use proxmox_tfa_api::{
+    RecoveryState, TfaChallenge, TfaConfig, TfaResponse, TfaUserData, U2fConfig, WebauthnConfig,
+};
+
+#[perlmod::package(name = "PVE::RS::TFA")]
+mod export {
+    use std::convert::TryInto;
+    use std::sync::Mutex;
+
+    use anyhow::{bail, format_err, Error};
+    use serde_bytes::ByteBuf;
+
+    use perlmod::Value;
+
+    use super::proxmox_tfa_api::api;
+    use super::{TfaConfig, UserAccess};
+
+    perlmod::declare_magic!(Box<Tfa> : &Tfa as "PVE::RS::TFA");
+
+    /// A TFA Config instance.
+    pub struct Tfa {
+        inner: Mutex<TfaConfig>,
+    }
+
+    /// Support `dclone` so this can be put into the `ccache` of `PVE::Cluster`.
+    #[export(name = "STORABLE_freeze", raw_return)]
+    fn storable_freeze(#[try_from_ref] this: &Tfa, cloning: bool) -> Result<Value, Error> {
+        if !cloning {
+            bail!("freezing TFA config not supported!");
+        }
+
+        // An alternative would be to literally just *serialize* the data, then we wouldn't even
+        // need to restrict it to `cloning=true`, but since `clone=true` means we're immediately
+        // attaching anyway, this should be safe enough...
+
+        let mut cloned = Box::new(Tfa {
+            inner: Mutex::new(this.inner.lock().unwrap().clone()),
+        });
+        let value = Value::new_pointer::<Tfa>(&mut *cloned);
+        let _perl = Box::leak(cloned);
+        Ok(value)
+    }
+
+    /// Instead of `thaw` we implement `attach` for `dclone`.
+    #[export(name = "STORABLE_attach", raw_return)]
+    fn storable_attach(
+        #[raw] class: Value,
+        cloning: bool,
+        #[raw] serialized: Value,
+    ) -> Result<Value, Error> {
+        if !cloning {
+            bail!("STORABLE_attach called with cloning=false");
+        }
+        let data = unsafe { Box::from_raw(serialized.pv_raw::<Tfa>()?) };
+
+        let mut hash = perlmod::Hash::new();
+        super::generate_legacy_config(&mut hash, &data.inner.lock().unwrap());
+        let hash = Value::Hash(hash);
+        let obj = Value::new_ref(&hash);
+        obj.bless_sv(&class)?;
+        hash.add_magic(MAGIC.with_value(data));
+        Ok(obj)
+
+        // Once we drop support for legacy authentication we can just do this:
+        // Ok(perlmod::instantiate_magic!(&class, MAGIC => data))
+    }
+
+    /// Parse a TFA configuration.
+    #[export(raw_return)]
+    fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
+        let mut inner: TfaConfig = serde_json::from_slice(config)
+            .map_err(Error::from)
+            .or_else(|_err| super::parse_old_config(config))
+            .map_err(|_err| {
+                format_err!("failed to parse TFA file, neither old style nor valid json")
+            })?;
+
+        // In PVE, the U2F and Webauthn configurations come from `datacenter.cfg`. In case this
+        // config was copied from PBS, let's clear it out:
+        inner.u2f = None;
+        inner.webauthn = None;
+
+        let mut hash = perlmod::Hash::new();
+        super::generate_legacy_config(&mut hash, &inner);
+        let hash = Value::Hash(hash);
+        let obj = Value::new_ref(&hash);
+        obj.bless_sv(&class)?;
+        hash.add_magic(MAGIC.with_value(Box::new(Tfa {
+            inner: Mutex::new(inner),
+        })));
+        Ok(obj)
+
+        // Once we drop support for legacy authentication we can just do this:
+        // Ok(perlmod::instantiate_magic!(
+        //     &class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) })
+        // ))
+    }
+
+    /// Write the configuration out into a JSON string.
+    #[export]
+    fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> {
+        let mut inner = this.inner.lock().unwrap();
+        let u2f = inner.u2f.take();
+        let webauthn = inner.webauthn.take();
+        let output = serde_json::to_vec(&*inner); // must not use `?` here
+        inner.u2f = u2f;
+        inner.webauthn = webauthn;
+        Ok(ByteBuf::from(output?))
+    }
+
+    /// Debug helper: serialize the TFA user data into a perl value.
+    #[export]
+    fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> {
+        let mut inner = this.inner.lock().unwrap();
+        let u2f = inner.u2f.take();
+        let webauthn = inner.webauthn.take();
+        let output = Ok(perlmod::to_value(&*inner)?);
+        inner.u2f = u2f;
+        inner.webauthn = webauthn;
+        output
+    }
+
+    /// Get a list of all the user names in this config.
+    /// PVE uses this to verify users and purge the invalid ones.
+    #[export]
+    fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> {
+        Ok(this.inner.lock().unwrap().users.keys().cloned().collect())
+    }
+
+    /// Remove a user from the TFA configuration.
+    #[export]
+    fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> {
+        Ok(this.inner.lock().unwrap().users.remove(userid).is_some())
+    }
+
+    /// Get the TFA data for a specific user.
+    #[export(raw_return)]
+    fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> {
+        perlmod::to_value(&this.inner.lock().unwrap().users.get(userid))
+    }
+
+    /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be
+    /// written out.
+    #[export]
+    fn add_u2f_registration(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner.u2f_registration_challenge(UserAccess::new(&raw_this)?, userid, description)
+    }
+
+    /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config
+    /// needs to be written out!
+    #[export]
+    fn finish_u2f_registration(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner.u2f_registration_finish(UserAccess::new(&raw_this)?, userid, challenge, response)
+    }
+
+    /// Check if a user has any TFA entries of a given type.
+    #[export]
+    fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> {
+        Ok(match this.inner.lock().unwrap().users.get(userid) {
+            Some(user) => match typename {
+                "totp" | "oath" => !user.totp.is_empty(),
+                "u2f" => !user.u2f.is_empty(),
+                "webauthn" => !user.webauthn.is_empty(),
+                "yubico" => !user.yubico.is_empty(),
+                "recovery" => match &user.recovery {
+                    Some(r) => r.count_available() > 0,
+                    None => false,
+                },
+                _ => bail!("unrecognized TFA type {:?}", typename),
+            },
+            None => false,
+        })
+    }
+
+    /// Generates a space separated list of yubico keys of this account.
+    #[export]
+    fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> {
+        Ok(this.inner.lock().unwrap().users.get(userid).map(|user| {
+            user.enabled_yubico_entries()
+                .fold(String::new(), |mut s, k| {
+                    if !s.is_empty() {
+                        s.push(' ');
+                    }
+                    s.push_str(k);
+                    s
+                })
+        }))
+    }
+
+    #[export]
+    fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) {
+        this.inner.lock().unwrap().u2f = config;
+    }
+
+    #[export]
+    fn set_webauthn_config(#[try_from_ref] this: &Tfa, config: Option<super::WebauthnConfig>) {
+        this.inner.lock().unwrap().webauthn = config;
+    }
+
+    /// Create an authentication challenge.
+    ///
+    /// Returns the challenge as a json string.
+    /// Returns `undef` if no second factor is configured.
+    #[export]
+    fn authentication_challenge(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+    ) -> Result<Option<String>, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        match inner.authentication_challenge(UserAccess::new(&raw_this)?, userid)? {
+            Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)),
+            None => Ok(None),
+        }
+    }
+
+    /// Get the recovery state (suitable for a challenge object).
+    #[export]
+    fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> {
+        this.inner
+            .lock()
+            .unwrap()
+            .users
+            .get(userid)
+            .and_then(|user| {
+                let state = user.recovery_state();
+                state.is_available().then(move || state)
+            })
+    }
+
+    /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
+    /// it.
+    ///
+    /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call
+    /// (to use up recovery keys!).
+    #[export]
+    fn authentication_verify(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        challenge: &str, //super::TfaChallenge,
+        response: &str,
+    ) -> Result<bool, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let challenge: super::TfaChallenge = serde_json::from_str(challenge)?;
+        let response: super::TfaResponse = response.parse()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner
+            .verify(UserAccess::new(&raw_this)?, userid, &challenge, response)
+            .map(|save| save.needs_saving())
+    }
+
+    /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI.
+    #[export]
+    fn get_current_totp_value(otp_uri: &str) -> Result<String, Error> {
+        let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?;
+        Ok(totp.time(std::time::SystemTime::now())?.to_string())
+    }
+
+    #[export]
+    fn api_list_user_tfa(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+    ) -> Result<Vec<api::TypedTfaInfo>, Error> {
+        api::list_user_tfa(&this.inner.lock().unwrap(), userid)
+    }
+
+    #[export]
+    fn api_get_tfa_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        id: &str,
+    ) -> Result<Option<api::TypedTfaInfo>, Error> {
+        api::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
+    }
+
+    /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
+    /// more tfa entries.
+    #[export]
+    fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
+        let mut this = this.inner.lock().unwrap();
+        match api::delete_tfa(&mut this, userid, id) {
+            Ok(has_entries_left) => Ok(has_entries_left),
+            Err(api::EntryNotFound) => bail!("no such entry"),
+        }
+    }
+
+    #[export]
+    fn api_list_tfa(
+        #[try_from_ref] this: &Tfa,
+        authid: &str,
+        top_level_allowed: bool,
+    ) -> Result<Vec<api::TfaUser>, Error> {
+        api::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
+    }
+
+    #[export]
+    fn api_add_tfa_entry(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: Option<String>,
+        totp: Option<String>,
+        value: Option<String>,
+        challenge: Option<String>,
+        ty: api::TfaType,
+    ) -> Result<api::TfaUpdateInfo, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        api::add_tfa_entry(
+            &mut this.inner.lock().unwrap(),
+            UserAccess::new(&raw_this)?,
+            userid,
+            description,
+            totp,
+            value,
+            challenge,
+            ty,
+        )
+    }
+
+    #[export]
+    fn api_update_tfa_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        id: &str,
+        description: Option<String>,
+        enable: Option<bool>,
+    ) -> Result<(), Error> {
+        match api::update_tfa_entry(
+            &mut this.inner.lock().unwrap(),
+            userid,
+            id,
+            description,
+            enable,
+        ) {
+            Ok(()) => Ok(()),
+            Err(api::EntryNotFound) => bail!("no such entry"),
+        }
+    }
+}
+
+/// Version 1 format of `/etc/pve/priv/tfa.cfg`
+/// ===========================================
+///
+/// The TFA configuration in priv/tfa.cfg format contains one line per user of the form:
+///
+///     USER:TYPE:DATA
+///
+/// DATA is a base64 encoded json object and its format depends on the type.
+///
+/// TYPEs
+/// -----
+///   - oath
+///
+///     This is a TOTP entry. In PVE, 1 such entry can contain multiple secrets, provided they use
+///     the same configuration.
+///
+///     DATA: {
+///       "keys" => "string of space separated TOTP secrets",
+///       "config" => { "step", "digits" },
+///     }
+///
+///   - yubico
+///
+///     Authentication using the Yubico API.
+///
+///     DATA: {
+///       "keys" => "string list of yubico keys",
+///     }
+///
+///   - u2f
+///
+///     Legacy U2F entry for the U2F browser API.
+///
+///     DATA: {
+///       "keyHandle" => "u2f key handle",
+///       "publicKey" => "u2f public key",
+///     }
+///
+fn parse_old_config(data: &[u8]) -> Result<TfaConfig, Error> {
+    let mut config = TfaConfig::default();
+
+    for line in data.split(|&b| b == b'\n') {
+        let line = trim_ascii_whitespace(line);
+        if line.is_empty() || line.starts_with(b"#") {
+            continue;
+        }
+
+        let mut parts = line.splitn(3, |&b| b == b':');
+        let ((user, ty), data) = parts
+            .next()
+            .zip(parts.next())
+            .zip(parts.next())
+            .ok_or_else(|| format_err!("bad line in tfa config"))?;
+
+        let user = std::str::from_utf8(user)
+            .map_err(|_err| format_err!("bad non-utf8 username in tfa config"))?;
+
+        let data = base64::decode(data)
+            .map_err(|err| format_err!("failed to decode data in tfa config entry - {}", err))?;
+
+        let entry = decode_old_entry(ty, &data, user)?;
+        config.users.insert(user.to_owned(), entry);
+    }
+
+    Ok(config)
+}
+
+fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result<TfaUserData, Error> {
+    let mut user_data = TfaUserData::default();
+
+    let info = proxmox_tfa_api::TfaInfo {
+        id: "v1-entry".to_string(),
+        description: "<old version 1 entry>".to_string(),
+        created: 0,
+        enable: true,
+    };
+
+    let value: JsonValue = serde_json::from_slice(data)
+        .map_err(|err| format_err!("failed to parse json data in tfa entry - {}", err))?;
+
+    match ty {
+        b"u2f" => user_data.u2f.push(proxmox_tfa_api::TfaEntry::from_parts(
+            info,
+            decode_old_u2f_entry(value)?,
+        )),
+        b"oath" => user_data.totp.extend(
+            decode_old_oath_entry(value, user)?
+                .into_iter()
+                .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)),
+        ),
+        b"yubico" => user_data.yubico.extend(
+            decode_old_yubico_entry(value)?
+                .into_iter()
+                .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)),
+        ),
+        other => match std::str::from_utf8(other) {
+            Ok(s) => bail!("unknown tfa.cfg entry type: {:?}", s),
+            Err(_) => bail!("unknown tfa.cfg entry type"),
+        },
+    };
+
+    Ok(user_data)
+}
+
+fn decode_old_u2f_entry(data: JsonValue) -> Result<proxmox_tfa::u2f::Registration, Error> {
+    let mut obj = match data {
+        JsonValue::Object(obj) => obj,
+        _ => bail!("bad json type for u2f registration"),
+    };
+
+    let reg = proxmox_tfa::u2f::Registration {
+        key: proxmox_tfa::u2f::RegisteredKey {
+            key_handle: base64::decode_config(
+                take_json_string(&mut obj, "keyHandle", "u2f")?,
+                base64::URL_SAFE_NO_PAD,
+            )
+            .map_err(|_| format_err!("handle in u2f entry"))?,
+            // PVE did not store this, but we only had U2F_V2 anyway...
+            version: "U2F_V2".to_string(),
+        },
+        public_key: base64::decode(take_json_string(&mut obj, "publicKey", "u2f")?)
+            .map_err(|_| format_err!("bad public key in u2f entry"))?,
+        certificate: Vec::new(),
+    };
+
+    if !obj.is_empty() {
+        bail!("invalid extra data in u2f entry");
+    }
+
+    Ok(reg)
+}
+
+fn decode_old_oath_entry(
+    data: JsonValue,
+    user: &str,
+) -> Result<Vec<proxmox_tfa::totp::Totp>, Error> {
+    let mut obj = match data {
+        JsonValue::Object(obj) => obj,
+        _ => bail!("bad json type for oath registration"),
+    };
+
+    let mut config = match obj.remove("config") {
+        Some(JsonValue::Object(obj)) => obj,
+        Some(_) => bail!("bad 'config' entry in oath tfa entry"),
+        None => bail!("missing 'config' entry in oath tfa entry"),
+    };
+
+    let mut totp = proxmox_tfa::totp::Totp::builder().account_name(user.to_owned());
+    if let Some(step) = config.remove("step") {
+        totp = totp.period(
+            usize_from_perl(step).ok_or_else(|| format_err!("bad 'step' value in oath config"))?,
+        );
+    }
+
+    if let Some(digits) = config.remove("digits") {
+        totp = totp.digits(
+            usize_from_perl(digits)
+                .and_then(|v| u8::try_from(v).ok())
+                .ok_or_else(|| format_err!("bad 'digits' value in oath config"))?,
+        );
+    }
+
+    if !config.is_empty() {
+        bail!("unhandled totp config keys in oath entry");
+    }
+
+    let mut out = Vec::new();
+
+    let keys = take_json_string(&mut obj, "keys", "oath")?;
+    for key in keys.split(|c| c == ',' || c == ';' || c == ' ') {
+        let key = trim_ascii_whitespace(key.as_bytes());
+        if key.is_empty() {
+            continue;
+        }
+
+        // key started out as a `String` and we only trimmed ASCII white space:
+        let key = unsafe { std::str::from_utf8_unchecked(key) };
+
+        // See PVE::OTP::oath_verify_otp
+        let key = if key.starts_with("v2-0x") {
+            hex::decode(&key[5..]).map_err(|_| format_err!("bad v2 hex key in oath entry"))?
+        } else if key.starts_with("v2-") {
+            base32::decode(base32::Alphabet::RFC4648 { padding: true }, &key[3..])
+                .ok_or_else(|| format_err!("bad v2 base32 key in oath entry"))?
+        } else if key.len() == 16 {
+            base32::decode(base32::Alphabet::RFC4648 { padding: true }, key)
+                .ok_or_else(|| format_err!("bad v1 base32 key in oath entry"))?
+        } else if key.len() == 40 {
+            hex::decode(key).map_err(|_| format_err!("bad v1 hex key in oath entry"))?
+        } else {
+            bail!("unrecognized key format, must be hex or base32 encoded");
+        };
+
+        out.push(totp.clone().secret(key).build());
+    }
+
+    Ok(out)
+}
+
+fn decode_old_yubico_entry(data: JsonValue) -> Result<Vec<String>, Error> {
+    let mut obj = match data {
+        JsonValue::Object(obj) => obj,
+        _ => bail!("bad json type for yubico registration"),
+    };
+
+    let mut out = Vec::new();
+
+    let keys = take_json_string(&mut obj, "keys", "yubico")?;
+    for key in keys.split(|c| c == ',' || c == ';' || c == ' ') {
+        let key = trim_ascii_whitespace(key.as_bytes());
+        if key.is_empty() {
+            continue;
+        }
+
+        // key started out as a `String` and we only trimmed ASCII white space:
+        out.push(unsafe { std::str::from_utf8_unchecked(key) }.to_owned());
+    }
+
+    Ok(out)
+}
+
+fn take_json_string(
+    data: &mut serde_json::Map<String, JsonValue>,
+    what: &'static str,
+    in_what: &'static str,
+) -> Result<String, Error> {
+    match data.remove(what) {
+        None => bail!("missing '{}' value in {} entry", what, in_what),
+        Some(JsonValue::String(s)) => Ok(s),
+        _ => bail!("bad '{}' value", what),
+    }
+}
+
+fn usize_from_perl(value: JsonValue) -> Option<usize> {
+    // we come from perl, numbers are strings!
+    match value {
+        JsonValue::Number(n) => n.as_u64().and_then(|n| usize::try_from(n).ok()),
+        JsonValue::String(s) => s.parse().ok(),
+        _ => None,
+    }
+}
+
+fn trim_ascii_whitespace_start(data: &[u8]) -> &[u8] {
+    match data.iter().position(|&c| !c.is_ascii_whitespace()) {
+        Some(from) => &data[from..],
+        None => &data[..],
+    }
+}
+
+fn trim_ascii_whitespace_end(data: &[u8]) -> &[u8] {
+    match data.iter().rposition(|&c| !c.is_ascii_whitespace()) {
+        Some(to) => &data[..to],
+        None => data,
+    }
+}
+
+fn trim_ascii_whitespace(data: &[u8]) -> &[u8] {
+    trim_ascii_whitespace_start(trim_ascii_whitespace_end(data))
+}
+
+fn create_legacy_data(data: &TfaUserData) -> bool {
+    if !data.webauthn.is_empty() || data.recovery.is_some() || data.u2f.len() > 1 {
+        // incompatible
+        return false;
+    }
+
+    if data.u2f.is_empty() && data.totp.is_empty() && data.yubico.is_empty() {
+        // no tfa configured
+        return false;
+    }
+
+    if let Some(totp) = data.totp.get(0) {
+        let algorithm = totp.entry.algorithm();
+        let digits = totp.entry.digits();
+        let period = totp.entry.period();
+        if period.subsec_nanos() != 0 {
+            return false;
+        }
+
+        for totp in data.totp.iter().skip(1) {
+            if totp.entry.algorithm() != algorithm
+                || totp.entry.digits() != digits
+                || totp.entry.period() != period
+            {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+fn b64u_np_encode<T: AsRef<[u8]>>(data: T) -> String {
+    base64::encode_config(data.as_ref(), base64::URL_SAFE_NO_PAD)
+}
+
+// fn b64u_np_decode<T: AsRef<[u8]>>(data: T) -> Result<Vec<u8>, base64::DecodeError> {
+//     base64::decode_config(data.as_ref(), base64::URL_SAFE_NO_PAD)
+// }
+
+fn generate_legacy_config(out: &mut perlmod::Hash, config: &TfaConfig) {
+    use perlmod::{Hash, Value};
+
+    let users = Hash::new();
+
+    for (user, data) in &config.users {
+        if !create_legacy_data(data) {
+            continue;
+        }
+
+        if let Some(u2f) = data.u2f.get(0) {
+            let data = Hash::new();
+            data.insert(
+                "publicKey",
+                Value::new_string(&base64::encode(&u2f.entry.public_key)),
+            );
+            data.insert(
+                "keyHandle",
+                Value::new_string(&b64u_np_encode(&u2f.entry.key.key_handle)),
+            );
+            let data = Value::new_ref(&data);
+
+            let entry = Hash::new();
+            entry.insert("type", Value::new_string("u2f"));
+            entry.insert("data", data);
+            users.insert(user, Value::new_ref(&entry));
+            continue;
+        }
+
+        if let Some(totp) = data.totp.get(0) {
+            let totp = &totp.entry;
+            let config = Hash::new();
+            config.insert("digits", Value::new_int(isize::from(totp.digits())));
+            config.insert("step", Value::new_int(totp.period().as_secs() as isize));
+
+            let mut keys = format!("v2-0x{}", hex::encode(totp.secret()));
+            for totp in data.totp.iter().skip(1) {
+                keys.push_str(" v2-0x");
+                keys.push_str(&hex::encode(totp.entry.secret()));
+            }
+
+            let data = Hash::new();
+            data.insert("config", Value::new_ref(&config));
+            data.insert("keys", Value::new_string(&keys));
+
+            let entry = Hash::new();
+            entry.insert("type", Value::new_string("oath"));
+            entry.insert("data", Value::new_ref(&data));
+            users.insert(user, Value::new_ref(&entry));
+            continue;
+        }
+
+        if let Some(entry) = data.yubico.get(0) {
+            let mut keys = entry.entry.clone();
+
+            for entry in data.yubico.iter().skip(1) {
+                keys.push(' ');
+                keys.push_str(&entry.entry);
+            }
+
+            let data = Hash::new();
+            data.insert("keys", Value::new_string(&keys));
+
+            let entry = Hash::new();
+            entry.insert("type", Value::new_string("yubico"));
+            entry.insert("data", Value::new_ref(&data));
+            users.insert(user, Value::new_ref(&entry));
+            continue;
+        }
+    }
+
+    out.insert("users", Value::new_ref(&users));
+}
+
+/// Attach the path to errors from [`nix::mkir()`].
+pub(crate) fn mkdir<P: AsRef<Path>>(path: P, mode: libc::mode_t) -> Result<(), Error> {
+    let path = path.as_ref();
+    match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) {
+        Ok(()) => Ok(()),
+        Err(nix::Error::Sys(Errno::EEXIST)) => Ok(()),
+        Err(err) => bail!("failed to create directory {:?}: {}", path, err),
+    }
+}
+
+#[cfg(debug_assertions)]
+#[derive(Clone)]
+#[repr(transparent)]
+pub struct UserAccess(perlmod::Value);
+
+#[cfg(debug_assertions)]
+impl UserAccess {
+    #[inline]
+    fn new(value: &perlmod::Value) -> Result<Self, Error> {
+        value
+            .dereference()
+            .ok_or_else(|| format_err!("bad TFA config object"))
+            .map(Self)
+    }
+
+    #[inline]
+    fn is_debug(&self) -> bool {
+        self.0
+            .as_hash()
+            .and_then(|v| v.get("-debug"))
+            .map(|v| v.iv() != 0)
+            .unwrap_or(false)
+    }
+}
+
+#[cfg(not(debug_assertions))]
+#[derive(Clone, Copy)]
+#[repr(transparent)]
+pub struct UserAccess;
+
+#[cfg(not(debug_assertions))]
+impl UserAccess {
+    #[inline]
+    const fn new(_value: &perlmod::Value) -> Result<Self, std::convert::Infallible> {
+        Ok(Self)
+    }
+
+    #[inline]
+    const fn is_debug(&self) -> bool {
+        false
+    }
+}
+
+/// Build the path to the challenge data file for a user.
+fn challenge_data_path(userid: &str, debug: bool) -> PathBuf {
+    if debug {
+        PathBuf::from(format!("./local-tfa-challenges/{}", userid))
+    } else {
+        PathBuf::from(format!("/run/pve-private/tfa-challenges/{}", userid))
+    }
+}
+
+impl proxmox_tfa_api::OpenUserChallengeData for UserAccess {
+    type Data = UserChallengeData;
+
+    fn open(&self, userid: &str) -> Result<UserChallengeData, Error> {
+        if self.is_debug() {
+            mkdir("./local-tfa-challenges", 0o700)?;
+        } else {
+            mkdir("/run/pve-private", 0o700)?;
+            mkdir("/run/pve-private/tfa-challenges", 0o700)?;
+        }
+
+        let path = challenge_data_path(userid, self.is_debug());
+
+        let mut file = std::fs::OpenOptions::new()
+            .create(true)
+            .read(true)
+            .write(true)
+            .truncate(false)
+            .mode(0o600)
+            .open(&path)
+            .map_err(|err| format_err!("failed to create challenge file {:?}: {}", &path, err))?;
+
+        UserChallengeData::lock_file(file.as_raw_fd())?;
+
+        // the file may be empty, so read to a temporary buffer first:
+        let mut data = Vec::with_capacity(4096);
+
+        file.read_to_end(&mut data).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        let inner = if data.is_empty() {
+            Default::default()
+        } else {
+            serde_json::from_slice(&data).map_err(|err| {
+                format_err!(
+                    "failed to parse challenge data for user {}: {}",
+                    userid,
+                    err
+                )
+            })?
+        };
+
+        Ok(UserChallengeData {
+            inner,
+            path,
+            lock: file,
+        })
+    }
+
+    /// `open` without creating the file if it doesn't exist, to finish WA authentications.
+    fn open_no_create(&self, userid: &str) -> Result<Option<UserChallengeData>, Error> {
+        let path = challenge_data_path(userid, self.is_debug());
+
+        let mut file = match std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .truncate(false)
+            .mode(0o600)
+            .open(&path)
+        {
+            Ok(file) => file,
+            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
+            Err(err) => return Err(err.into()),
+        };
+
+        UserChallengeData::lock_file(file.as_raw_fd())?;
+
+        let inner = serde_json::from_reader(&mut file).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        Ok(Some(UserChallengeData {
+            inner,
+            path,
+            lock: file,
+        }))
+    }
+}
+
+/// Container of `TfaUserChallenges` with the corresponding file lock guard.
+///
+/// Basically provides the TFA API to the REST server by persisting, updating and verifying active
+/// challenges.
+pub struct UserChallengeData {
+    inner: proxmox_tfa_api::TfaUserChallenges,
+    path: PathBuf,
+    lock: File,
+}
+
+impl proxmox_tfa_api::UserChallengeAccess for UserChallengeData {
+    fn get_mut(&mut self) -> &mut proxmox_tfa_api::TfaUserChallenges {
+        &mut self.inner
+    }
+
+    fn save(self) -> Result<(), Error> {
+        UserChallengeData::save(self)
+    }
+}
+
+impl UserChallengeData {
+    fn lock_file(fd: RawFd) -> Result<(), Error> {
+        let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
+
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            bail!("failed to lock tfa user challenge data: {}", err);
+        }
+
+        Ok(())
+    }
+
+    /// Rewind & truncate the file for an update.
+    fn rewind(&mut self) -> Result<(), Error> {
+        use std::io::{Seek, SeekFrom};
+
+        let pos = self.lock.seek(SeekFrom::Start(0))?;
+        if pos != 0 {
+            bail!(
+                "unexpected result trying to rewind file, position is {}",
+                pos
+            );
+        }
+
+        let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) };
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            bail!("failed to truncate challenge data: {}", err);
+        }
+
+        Ok(())
+    }
+
+    /// Save the current data. Note that we do not replace the file here since we lock the file
+    /// itself, as it is in `/run`, and the typical error case for this particular situation
+    /// (machine loses power) simply prevents some login, but that'll probably fail anyway for
+    /// other reasons then...
+    ///
+    /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
+    /// way also unlocks early.
+    fn save(mut self) -> Result<(), Error> {
+        self.rewind()?;
+
+        serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| {
+            format_err!("failed to update challenge file {:?}: {}", self.path, err)
+        })?;
+
+        Ok(())
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/api.rs b/pve-rs/src/tfa/proxmox_tfa_api/api.rs
new file mode 100644
index 0000000..6be5205
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/api.rs
@@ -0,0 +1,487 @@
+//! API interaction module.
+//!
+//! This defines the methods & types used in the authentication and TFA configuration API between
+//! PBS, PVE, PMG.
+
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+
+use proxmox_tfa::totp::Totp;
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
+use super::{OpenUserChallengeData, TfaConfig, TfaInfo, TfaUserData};
+
+#[cfg_attr(feature = "api-types", api)]
+/// A TFA entry type.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TfaType {
+    /// A TOTP entry type.
+    Totp,
+    /// A U2F token entry.
+    U2f,
+    /// A Webauthn token entry.
+    Webauthn,
+    /// Recovery tokens.
+    Recovery,
+    /// Yubico authentication entry.
+    Yubico,
+}
+
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        type: { type: TfaType },
+        info: { type: TfaInfo },
+    },
+))]
+/// A TFA entry for a user.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TypedTfaInfo {
+    #[serde(rename = "type")]
+    pub ty: TfaType,
+
+    #[serde(flatten)]
+    pub info: TfaInfo,
+}
+
+fn to_data(data: &TfaUserData) -> Vec<TypedTfaInfo> {
+    let mut out = Vec::with_capacity(
+        data.totp.len()
+            + data.u2f.len()
+            + data.webauthn.len()
+            + data.yubico.len()
+            + if data.recovery().is_some() { 1 } else { 0 },
+    );
+    if let Some(recovery) = data.recovery() {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Recovery,
+            info: TfaInfo::recovery(recovery.created),
+        })
+    }
+    for entry in &data.totp {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Totp,
+            info: entry.info.clone(),
+        });
+    }
+    for entry in &data.webauthn {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Webauthn,
+            info: entry.info.clone(),
+        });
+    }
+    for entry in &data.u2f {
+        out.push(TypedTfaInfo {
+            ty: TfaType::U2f,
+            info: entry.info.clone(),
+        });
+    }
+    for entry in &data.yubico {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Yubico,
+            info: entry.info.clone(),
+        });
+    }
+    out
+}
+
+/// Iterate through tuples of `(type, index, id)`.
+fn tfa_id_iter(data: &TfaUserData) -> impl Iterator<Item = (TfaType, usize, &str)> {
+    data.totp
+        .iter()
+        .enumerate()
+        .map(|(i, entry)| (TfaType::Totp, i, entry.info.id.as_str()))
+        .chain(
+            data.webauthn
+                .iter()
+                .enumerate()
+                .map(|(i, entry)| (TfaType::Webauthn, i, entry.info.id.as_str())),
+        )
+        .chain(
+            data.u2f
+                .iter()
+                .enumerate()
+                .map(|(i, entry)| (TfaType::U2f, i, entry.info.id.as_str())),
+        )
+        .chain(
+            data.yubico
+                .iter()
+                .enumerate()
+                .map(|(i, entry)| (TfaType::Yubico, i, entry.info.id.as_str())),
+        )
+        .chain(
+            data.recovery
+                .iter()
+                .map(|_| (TfaType::Recovery, 0, "recovery")),
+        )
+}
+
+/// API call implementation for `GET /access/tfa/{userid}`
+///
+/// Permissions for accessing `userid` must have been verified by the caller.
+pub fn list_user_tfa(config: &TfaConfig, userid: &str) -> Result<Vec<TypedTfaInfo>, Error> {
+    Ok(match config.users.get(userid) {
+        Some(data) => to_data(data),
+        None => Vec::new(),
+    })
+}
+
+/// API call implementation for `GET /access/tfa/{userid}/{ID}`.
+///
+/// Permissions for accessing `userid` must have been verified by the caller.
+///
+/// In case this returns `None` a `NOT_FOUND` http error should be returned.
+pub fn get_tfa_entry(
+    config: &TfaConfig,
+    userid: &str,
+    id: &str,
+) -> Result<Option<TypedTfaInfo>, Error> {
+    let user_data = match config.users.get(userid) {
+        Some(u) => u,
+        None => return Ok(None),
+    };
+
+    Ok(Some(
+        match {
+            // scope to prevent the temporary iter from borrowing across the whole match
+            let entry = tfa_id_iter(&user_data).find(|(_ty, _index, entry_id)| id == *entry_id);
+            entry.map(|(ty, index, _)| (ty, index))
+        } {
+            Some((TfaType::Recovery, _)) => match user_data.recovery() {
+                Some(recovery) => TypedTfaInfo {
+                    ty: TfaType::Recovery,
+                    info: TfaInfo::recovery(recovery.created),
+                },
+                None => return Ok(None),
+            },
+            Some((TfaType::Totp, index)) => {
+                TypedTfaInfo {
+                    ty: TfaType::Totp,
+                    // `into_iter().nth()` to *move* out of it
+                    info: user_data.totp.iter().nth(index).unwrap().info.clone(),
+                }
+            }
+            Some((TfaType::Webauthn, index)) => TypedTfaInfo {
+                ty: TfaType::Webauthn,
+                info: user_data.webauthn.iter().nth(index).unwrap().info.clone(),
+            },
+            Some((TfaType::U2f, index)) => TypedTfaInfo {
+                ty: TfaType::U2f,
+                info: user_data.u2f.iter().nth(index).unwrap().info.clone(),
+            },
+            Some((TfaType::Yubico, index)) => TypedTfaInfo {
+                ty: TfaType::Yubico,
+                info: user_data.yubico.iter().nth(index).unwrap().info.clone(),
+            },
+            None => return Ok(None),
+        },
+    ))
+}
+
+pub struct EntryNotFound;
+
+/// API call implementation for `DELETE /access/tfa/{userid}/{ID}`.
+///
+/// The caller must have already verified the user's password.
+///
+/// The TFA config must be WRITE locked.
+///
+/// The caller must *save* the config afterwards!
+///
+/// Errors only if the entry was not found.
+///
+/// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* more
+/// tfa entries.
+pub fn delete_tfa(config: &mut TfaConfig, userid: &str, id: String) -> Result<bool, EntryNotFound> {
+    let user_data = config.users.get_mut(userid).ok_or(EntryNotFound)?;
+
+    match {
+        // scope to prevent the temporary iter from borrowing across the whole match
+        let entry = tfa_id_iter(&user_data).find(|(_, _, entry_id)| id == *entry_id);
+        entry.map(|(ty, index, _)| (ty, index))
+    } {
+        Some((TfaType::Recovery, _)) => user_data.recovery = None,
+        Some((TfaType::Totp, index)) => drop(user_data.totp.remove(index)),
+        Some((TfaType::Webauthn, index)) => drop(user_data.webauthn.remove(index)),
+        Some((TfaType::U2f, index)) => drop(user_data.u2f.remove(index)),
+        Some((TfaType::Yubico, index)) => drop(user_data.yubico.remove(index)),
+        None => return Err(EntryNotFound),
+    }
+
+    if user_data.is_empty() {
+        config.users.remove(userid);
+        Ok(false)
+    } else {
+        Ok(true)
+    }
+}
+
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        "userid": { type: Userid },
+        "entries": {
+            type: Array,
+            items: { type: TypedTfaInfo },
+        },
+    },
+))]
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+/// Over the API we only provide the descriptions for TFA data.
+pub struct TfaUser {
+    /// The user this entry belongs to.
+    userid: String,
+
+    /// TFA entries.
+    entries: Vec<TypedTfaInfo>,
+}
+
+/// API call implementation for `GET /access/tfa`.
+///
+/// Caller needs to have performed the required privilege checks already.
+pub fn list_tfa(
+    config: &TfaConfig,
+    authid: &str,
+    top_level_allowed: bool,
+) -> Result<Vec<TfaUser>, Error> {
+    let tfa_data = &config.users;
+
+    let mut out = Vec::<TfaUser>::new();
+    if top_level_allowed {
+        for (user, data) in tfa_data {
+            out.push(TfaUser {
+                userid: user.clone(),
+                entries: to_data(data),
+            });
+        }
+    } else if let Some(data) = { tfa_data }.get(authid) {
+        out.push(TfaUser {
+            userid: authid.into(),
+            entries: to_data(data),
+        });
+    }
+
+    Ok(out)
+}
+
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        recovery: {
+            description: "A list of recovery codes as integers.",
+            type: Array,
+            items: {
+                type: Integer,
+                description: "A one-time usable recovery code entry.",
+            },
+        },
+    },
+))]
+/// The result returned when adding TFA entries to a user.
+#[derive(Default, Serialize)]
+pub struct TfaUpdateInfo {
+    /// The id if a newly added TFA entry.
+    id: Option<String>,
+
+    /// When adding u2f entries, this contains a challenge the user must respond to in order to
+    /// finish the registration.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    challenge: Option<String>,
+
+    /// When adding recovery codes, this contains the list of codes to be displayed to the user
+    /// this one time.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    recovery: Vec<String>,
+}
+
+impl TfaUpdateInfo {
+    fn id(id: String) -> Self {
+        Self {
+            id: Some(id),
+            ..Default::default()
+        }
+    }
+}
+
+fn need_description(description: Option<String>) -> Result<String, Error> {
+    description.ok_or_else(|| format_err!("'description' is required for new entries"))
+}
+
+/// API call implementation for `POST /access/tfa/{userid}`.
+///
+/// Permissions for accessing `userid` must have been verified by the caller.
+///
+/// The caller must have already verified the user's password!
+pub fn add_tfa_entry<A: OpenUserChallengeData>(
+    config: &mut TfaConfig,
+    access: A,
+    userid: &str,
+    description: Option<String>,
+    totp: Option<String>,
+    value: Option<String>,
+    challenge: Option<String>,
+    r#type: TfaType,
+) -> Result<TfaUpdateInfo, Error> {
+    match r#type {
+        TfaType::Totp => {
+            if challenge.is_some() {
+                bail!("'challenge' parameter is invalid for 'totp' entries");
+            }
+
+            add_totp(config, userid, need_description(description)?, totp, value)
+        }
+        TfaType::Webauthn => {
+            if totp.is_some() {
+                bail!("'totp' parameter is invalid for 'webauthn' entries");
+            }
+
+            add_webauthn(config, access, userid, description, challenge, value)
+        }
+        TfaType::U2f => {
+            if totp.is_some() {
+                bail!("'totp' parameter is invalid for 'u2f' entries");
+            }
+
+            add_u2f(config, access, userid, description, challenge, value)
+        }
+        TfaType::Recovery => {
+            if totp.or(value).or(challenge).is_some() {
+                bail!("generating recovery tokens does not allow additional parameters");
+            }
+
+            let recovery = config.add_recovery(&userid)?;
+
+            Ok(TfaUpdateInfo {
+                id: Some("recovery".to_string()),
+                recovery,
+                ..Default::default()
+            })
+        }
+        TfaType::Yubico => {
+            if totp.or(challenge).is_some() {
+                bail!("'totp' and 'challenge' parameters are invalid for 'yubico' entries");
+            }
+
+            add_yubico(config, userid, need_description(description)?, value)
+        }
+    }
+}
+
+fn add_totp(
+    config: &mut TfaConfig,
+    userid: &str,
+    description: String,
+    totp: Option<String>,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    let (totp, value) = match (totp, value) {
+        (Some(totp), Some(value)) => (totp, value),
+        _ => bail!("'totp' type requires both 'totp' and 'value' parameters"),
+    };
+
+    let totp: Totp = totp.parse()?;
+    if totp
+        .verify(&value, std::time::SystemTime::now(), -1..=1)?
+        .is_none()
+    {
+        bail!("failed to verify TOTP challenge");
+    }
+    config
+        .add_totp(userid, description, totp)
+        .map(TfaUpdateInfo::id)
+}
+
+fn add_yubico(
+    config: &mut TfaConfig,
+    userid: &str,
+    description: String,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    let key = value.ok_or_else(|| format_err!("missing 'value' parameter for 'yubico' entry"))?;
+    config
+        .add_yubico(userid, description, key)
+        .map(TfaUpdateInfo::id)
+}
+
+fn add_u2f<A: OpenUserChallengeData>(
+    config: &mut TfaConfig,
+    access: A,
+    userid: &str,
+    description: Option<String>,
+    challenge: Option<String>,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    match challenge {
+        None => config
+            .u2f_registration_challenge(access, userid, need_description(description)?)
+            .map(|c| TfaUpdateInfo {
+                challenge: Some(c),
+                ..Default::default()
+            }),
+        Some(challenge) => {
+            let value = value.ok_or_else(|| {
+                format_err!("missing 'value' parameter (u2f challenge response missing)")
+            })?;
+            config
+                .u2f_registration_finish(access, userid, &challenge, &value)
+                .map(TfaUpdateInfo::id)
+        }
+    }
+}
+
+fn add_webauthn<A: OpenUserChallengeData>(
+    config: &mut TfaConfig,
+    access: A,
+    userid: &str,
+    description: Option<String>,
+    challenge: Option<String>,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    match challenge {
+        None => config
+            .webauthn_registration_challenge(access, &userid, need_description(description)?)
+            .map(|c| TfaUpdateInfo {
+                challenge: Some(c),
+                ..Default::default()
+            }),
+        Some(challenge) => {
+            let value = value.ok_or_else(|| {
+                format_err!("missing 'value' parameter (webauthn challenge response missing)")
+            })?;
+            config
+                .webauthn_registration_finish(access, &userid, &challenge, &value)
+                .map(TfaUpdateInfo::id)
+        }
+    }
+}
+
+/// API call implementation for `PUT /access/tfa/{userid}/{id}`.
+///
+/// The caller must have already verified the user's password.
+///
+/// Errors only if the entry was not found.
+pub fn update_tfa_entry(
+    config: &mut TfaConfig,
+    userid: &str,
+    id: &str,
+    description: Option<String>,
+    enable: Option<bool>,
+) -> Result<(), EntryNotFound> {
+    let mut entry = config
+        .users
+        .get_mut(userid)
+        .and_then(|user| user.find_entry_mut(id))
+        .ok_or(EntryNotFound)?;
+
+    if let Some(description) = description {
+        entry.description = description;
+    }
+
+    if let Some(enable) = enable {
+        entry.enable = enable;
+    }
+
+    Ok(())
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/mod.rs b/pve-rs/src/tfa/proxmox_tfa_api/mod.rs
new file mode 100644
index 0000000..bd5ab27
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/mod.rs
@@ -0,0 +1,1003 @@
+//! TFA configuration and user data.
+//!
+//! This is the same as used in PBS but without the `#[api]` type.
+//!
+//! We may want to move this into a shared crate making the `#[api]` macro feature-gated!
+
+use std::collections::HashMap;
+
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use webauthn_rs::proto::Credential as WebauthnCredential;
+use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
+
+use proxmox_tfa::totp::Totp;
+use proxmox_uuid::Uuid;
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
+mod serde_tools;
+
+mod recovery;
+mod u2f;
+mod webauthn;
+
+pub mod api;
+
+pub use recovery::RecoveryState;
+pub use u2f::U2fConfig;
+pub use webauthn::WebauthnConfig;
+
+use recovery::Recovery;
+use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge};
+use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge};
+
+trait IsExpired {
+    fn is_expired(&self, at_epoch: i64) -> bool;
+}
+
+pub trait OpenUserChallengeData: Clone {
+    type Data: UserChallengeAccess;
+
+    fn open(&self, userid: &str) -> Result<Self::Data, Error>;
+    fn open_no_create(&self, userid: &str) -> Result<Option<Self::Data>, Error>;
+}
+
+pub trait UserChallengeAccess: Sized {
+    //fn open(userid: &str) -> Result<Self, Error>;
+    //fn open_no_create(userid: &str) -> Result<Option<Self>, Error>;
+    fn get_mut(&mut self) -> &mut TfaUserChallenges;
+    fn save(self) -> Result<(), Error>;
+}
+
+const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60;
+
+/// TFA Configuration for this instance.
+#[derive(Clone, Default, Deserialize, Serialize)]
+pub struct TfaConfig {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub u2f: Option<U2fConfig>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub webauthn: Option<WebauthnConfig>,
+
+    #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
+    pub users: TfaUsers,
+}
+
+/// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
+fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
+    u2f.as_ref()
+        .map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
+}
+
+/// Helper to get a u2f instance from a u2f config.
+///
+/// This is outside of `TfaConfig` to not borrow its `&self`.
+fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
+    get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
+}
+
+/// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one
+/// configured.
+fn get_webauthn(waconfig: &Option<WebauthnConfig>) -> Option<Webauthn<WebauthnConfig>> {
+    waconfig.clone().map(Webauthn::new)
+}
+
+/// Helper to get a u2f instance from a u2f config.
+///
+/// This is outside of `TfaConfig` to not borrow its `&self`.
+fn check_webauthn(waconfig: &Option<WebauthnConfig>) -> Result<Webauthn<WebauthnConfig>, Error> {
+    get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available"))
+}
+
+impl TfaConfig {
+    // Get a u2f registration challenge.
+    pub fn u2f_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let u2f = check_u2f(&self.u2f)?;
+
+        self.users
+            .entry(userid.to_owned())
+            .or_default()
+            .u2f_registration_challenge(access, userid, &u2f, description)
+    }
+
+    /// Finish a u2f registration challenge.
+    pub fn u2f_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let u2f = check_u2f(&self.u2f)?;
+
+        match self.users.get_mut(userid) {
+            Some(user) => user.u2f_registration_finish(access, userid, &u2f, challenge, response),
+            None => bail!("no such challenge"),
+        }
+    }
+
+    /// Get a webauthn registration challenge.
+    fn webauthn_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        user: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let webauthn = check_webauthn(&self.webauthn)?;
+
+        self.users
+            .entry(user.to_owned())
+            .or_default()
+            .webauthn_registration_challenge(access, webauthn, user, description)
+    }
+
+    /// Finish a webauthn registration challenge.
+    fn webauthn_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let webauthn = check_webauthn(&self.webauthn)?;
+
+        let response: webauthn_rs::proto::RegisterPublicKeyCredential =
+            serde_json::from_str(response)
+                .map_err(|err| format_err!("error parsing challenge response: {}", err))?;
+
+        match self.users.get_mut(userid) {
+            Some(user) => {
+                user.webauthn_registration_finish(access, webauthn, userid, challenge, response)
+            }
+            None => bail!("no such challenge"),
+        }
+    }
+
+    /// Add a TOTP entry for a user.
+    ///
+    /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
+    /// themselves.
+    pub fn add_totp(
+        &mut self,
+        userid: &str,
+        description: String,
+        value: Totp,
+    ) -> Result<String, Error> {
+        Ok(self
+            .users
+            .entry(userid.to_owned())
+            .or_default()
+            .add_totp(description, value))
+    }
+
+    /// Add a Yubico key to a user.
+    ///
+    /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
+    /// themselves.
+    pub fn add_yubico(
+        &mut self,
+        userid: &str,
+        description: String,
+        key: String,
+    ) -> Result<String, Error> {
+        Ok(self
+            .users
+            .entry(userid.to_owned())
+            .or_default()
+            .add_yubico(description, key))
+    }
+
+    /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
+    fn add_recovery(&mut self, userid: &str) -> Result<Vec<String>, Error> {
+        self.users
+            .entry(userid.to_owned())
+            .or_default()
+            .add_recovery()
+    }
+
+    /// Get a two factor authentication challenge for a user, if the user has TFA set up.
+    pub fn authentication_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+    ) -> Result<Option<TfaChallenge>, Error> {
+        match self.users.get_mut(userid) {
+            Some(udata) => udata.challenge(
+                access,
+                userid,
+                get_webauthn(&self.webauthn),
+                get_u2f(&self.u2f).as_ref(),
+            ),
+            None => Ok(None),
+        }
+    }
+
+    /// Verify a TFA challenge.
+    pub fn verify<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        challenge: &TfaChallenge,
+        response: TfaResponse,
+    ) -> Result<NeedsSaving, Error> {
+        match self.users.get_mut(userid) {
+            Some(user) => match response {
+                TfaResponse::Totp(value) => user.verify_totp(&value),
+                TfaResponse::U2f(value) => match &challenge.u2f {
+                    Some(challenge) => {
+                        let u2f = check_u2f(&self.u2f)?;
+                        user.verify_u2f(access.clone(), userid, u2f, &challenge.challenge, value)
+                    }
+                    None => bail!("no u2f factor available for user '{}'", userid),
+                },
+                TfaResponse::Webauthn(value) => {
+                    let webauthn = check_webauthn(&self.webauthn)?;
+                    user.verify_webauthn(access.clone(), userid, webauthn, value)
+                }
+                TfaResponse::Recovery(value) => {
+                    user.verify_recovery(&value)?;
+                    return Ok(NeedsSaving::Yes);
+                }
+            },
+            None => bail!("no 2nd factor available for user '{}'", userid),
+        }?;
+
+        Ok(NeedsSaving::No)
+    }
+}
+
+#[must_use = "must save the config in order to ensure one-time use of recovery keys"]
+#[derive(Clone, Copy)]
+pub enum NeedsSaving {
+    No,
+    Yes,
+}
+
+impl NeedsSaving {
+    /// Convenience method so we don't need to import the type name.
+    pub fn needs_saving(self) -> bool {
+        matches!(self, NeedsSaving::Yes)
+    }
+}
+
+/// Mapping of userid to TFA entry.
+pub type TfaUsers = HashMap<String, TfaUserData>;
+
+/// TFA data for a user.
+#[derive(Clone, Default, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+#[serde(rename_all = "kebab-case")]
+#[serde(bound(deserialize = "", serialize = ""))]
+pub struct TfaUserData {
+    /// Totp keys for a user.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub totp: Vec<TfaEntry<Totp>>,
+
+    /// Registered u2f tokens for a user.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub u2f: Vec<TfaEntry<u2f::Registration>>,
+
+    /// Registered webauthn tokens for a user.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub webauthn: Vec<TfaEntry<WebauthnCredential>>,
+
+    /// Recovery keys. (Unordered OTP values).
+    #[serde(skip_serializing_if = "Recovery::option_is_empty", default)]
+    pub recovery: Option<Recovery>,
+
+    /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this
+    /// available for PVE, where the yubico API server configuration is part if the realm.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub yubico: Vec<TfaEntry<String>>,
+}
+
+impl TfaUserData {
+    /// Shortcut to get the recovery entry only if it is not empty!
+    pub fn recovery(&self) -> Option<&Recovery> {
+        if Recovery::option_is_empty(&self.recovery) {
+            None
+        } else {
+            self.recovery.as_ref()
+        }
+    }
+
+    /// `true` if no second factors exist
+    pub fn is_empty(&self) -> bool {
+        self.totp.is_empty()
+            && self.u2f.is_empty()
+            && self.webauthn.is_empty()
+            && self.yubico.is_empty()
+            && self.recovery().is_none()
+    }
+
+    /// Find an entry by id, except for the "recovery" entry which we're currently treating
+    /// specially.
+    pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
+        for entry in &mut self.totp {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
+        for entry in &mut self.webauthn {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
+        for entry in &mut self.u2f {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
+        for entry in &mut self.yubico {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
+        None
+    }
+
+    /// Create a u2f registration challenge.
+    ///
+    /// The description is required at this point already mostly to better be able to identify such
+    /// challenges in the tfa config file if necessary. The user otherwise has no access to this
+    /// information at this point, as the challenge is identified by its actual challenge data
+    /// instead.
+    fn u2f_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        u2f: &u2f::U2f,
+        description: String,
+    ) -> Result<String, Error> {
+        let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
+
+        let mut data = access.open(userid)?;
+        data.get_mut()
+            .u2f_registrations
+            .push(U2fRegistrationChallenge::new(
+                challenge.clone(),
+                description,
+            ));
+        data.save()?;
+
+        Ok(challenge)
+    }
+
+    fn u2f_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        u2f: &u2f::U2f,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let mut data = access.open(userid)?;
+        let entry = data
+            .get_mut()
+            .u2f_registration_finish(u2f, challenge, response)?;
+        data.save()?;
+
+        let id = entry.info.id.clone();
+        self.u2f.push(entry);
+        Ok(id)
+    }
+
+    /// Create a webauthn registration challenge.
+    ///
+    /// The description is required at this point already mostly to better be able to identify such
+    /// challenges in the tfa config file if necessary. The user otherwise has no access to this
+    /// information at this point, as the challenge is identified by its actual challenge data
+    /// instead.
+    fn webauthn_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        mut webauthn: Webauthn<WebauthnConfig>,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let cred_ids: Vec<_> = self
+            .enabled_webauthn_entries()
+            .map(|cred| cred.cred_id.clone())
+            .collect();
+
+        let (challenge, state) = webauthn.generate_challenge_register_options(
+            userid.as_bytes().to_vec(),
+            userid.to_owned(),
+            userid.to_owned(),
+            Some(cred_ids),
+            Some(UserVerificationPolicy::Discouraged),
+        )?;
+
+        let challenge_string = challenge.public_key.challenge.to_string();
+        let challenge = serde_json::to_string(&challenge)?;
+
+        let mut data = access.open(userid)?;
+        data.get_mut()
+            .webauthn_registrations
+            .push(WebauthnRegistrationChallenge::new(
+                state,
+                challenge_string,
+                description,
+            ));
+        data.save()?;
+
+        Ok(challenge)
+    }
+
+    /// Finish a webauthn registration. The challenge should correspond to an output of
+    /// `webauthn_registration_challenge`. The response should come directly from the client.
+    fn webauthn_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        webauthn: Webauthn<WebauthnConfig>,
+        userid: &str,
+        challenge: &str,
+        response: webauthn_rs::proto::RegisterPublicKeyCredential,
+    ) -> Result<String, Error> {
+        let mut data = access.open(userid)?;
+        let entry = data.get_mut().webauthn_registration_finish(
+            webauthn,
+            challenge,
+            response,
+            &self.webauthn,
+        )?;
+        data.save()?;
+
+        let id = entry.info.id.clone();
+        self.webauthn.push(entry);
+        Ok(id)
+    }
+
+    fn add_totp(&mut self, description: String, totp: Totp) -> String {
+        let entry = TfaEntry::new(description, totp);
+        let id = entry.info.id.clone();
+        self.totp.push(entry);
+        id
+    }
+
+    fn add_yubico(&mut self, description: String, key: String) -> String {
+        let entry = TfaEntry::new(description, key);
+        let id = entry.info.id.clone();
+        self.yubico.push(entry);
+        id
+    }
+
+    /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
+    fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
+        if self.recovery.is_some() {
+            bail!("user already has recovery keys");
+        }
+
+        let (recovery, original) = Recovery::generate()?;
+
+        self.recovery = Some(recovery);
+
+        Ok(original)
+    }
+
+    /// Helper to iterate over enabled totp entries.
+    fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
+        self.totp
+            .iter()
+            .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
+    }
+
+    /// Helper to iterate over enabled u2f entries.
+    fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
+        self.u2f
+            .iter()
+            .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
+    }
+
+    /// Helper to iterate over enabled u2f entries.
+    fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> {
+        self.webauthn
+            .iter()
+            .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
+    }
+
+    /// Helper to iterate over enabled yubico entries.
+    pub fn enabled_yubico_entries(&self) -> impl Iterator<Item = &str> {
+        self.yubico.iter().filter_map(|e| {
+            if e.info.enable {
+                Some(e.entry.as_str())
+            } else {
+                None
+            }
+        })
+    }
+
+    /// Verify a totp challenge. The `value` should be the totp digits as plain text.
+    fn verify_totp(&self, value: &str) -> Result<(), Error> {
+        let now = std::time::SystemTime::now();
+
+        for entry in self.enabled_totp_entries() {
+            if entry.verify(value, now, -1..=1)?.is_some() {
+                return Ok(());
+            }
+        }
+
+        bail!("totp verification failed");
+    }
+
+    /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
+    pub fn challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        webauthn: Option<Webauthn<WebauthnConfig>>,
+        u2f: Option<&u2f::U2f>,
+    ) -> Result<Option<TfaChallenge>, Error> {
+        if self.is_empty() {
+            return Ok(None);
+        }
+
+        Ok(Some(TfaChallenge {
+            totp: self.totp.iter().any(|e| e.info.enable),
+            recovery: RecoveryState::from(&self.recovery),
+            webauthn: match webauthn {
+                Some(webauthn) => self.webauthn_challenge(access.clone(), userid, webauthn)?,
+                None => None,
+            },
+            u2f: match u2f {
+                Some(u2f) => self.u2f_challenge(access.clone(), userid, u2f)?,
+                None => None,
+            },
+            yubico: self.yubico.iter().any(|e| e.info.enable),
+        }))
+    }
+
+    /// Get the recovery state.
+    pub fn recovery_state(&self) -> RecoveryState {
+        RecoveryState::from(&self.recovery)
+    }
+
+    /// Generate an optional webauthn challenge.
+    fn webauthn_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        mut webauthn: Webauthn<WebauthnConfig>,
+    ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
+        if self.webauthn.is_empty() {
+            return Ok(None);
+        }
+
+        let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect();
+
+        if creds.is_empty() {
+            return Ok(None);
+        }
+
+        let (challenge, state) = webauthn
+            .generate_challenge_authenticate(creds, Some(UserVerificationPolicy::Discouraged))?;
+        let challenge_string = challenge.public_key.challenge.to_string();
+        let mut data = access.open(userid)?;
+        data.get_mut()
+            .webauthn_auths
+            .push(WebauthnAuthChallenge::new(state, challenge_string));
+        data.save()?;
+
+        Ok(Some(challenge))
+    }
+
+    /// Generate an optional u2f challenge.
+    fn u2f_challenge<A: OpenUserChallengeData>(
+        &self,
+        access: A,
+        userid: &str,
+        u2f: &u2f::U2f,
+    ) -> Result<Option<U2fChallenge>, Error> {
+        if self.u2f.is_empty() {
+            return Ok(None);
+        }
+
+        let keys: Vec<proxmox_tfa::u2f::RegisteredKey> = self
+            .enabled_u2f_entries()
+            .map(|registration| registration.key.clone())
+            .collect();
+
+        if keys.is_empty() {
+            return Ok(None);
+        }
+
+        let challenge = U2fChallenge {
+            challenge: u2f.auth_challenge()?,
+            keys,
+        };
+
+        let mut data = access.open(userid)?;
+        data.get_mut()
+            .u2f_auths
+            .push(U2fChallengeEntry::new(&challenge));
+        data.save()?;
+
+        Ok(Some(challenge))
+    }
+
+    /// Verify a u2f response.
+    fn verify_u2f<A: OpenUserChallengeData>(
+        &self,
+        access: A,
+        userid: &str,
+        u2f: u2f::U2f,
+        challenge: &proxmox_tfa::u2f::AuthChallenge,
+        response: Value,
+    ) -> Result<(), Error> {
+        let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let response: proxmox_tfa::u2f::AuthResponse = serde_json::from_value(response)
+            .map_err(|err| format_err!("invalid u2f response: {}", err))?;
+
+        if let Some(entry) = self
+            .enabled_u2f_entries()
+            .find(|e| e.key.key_handle == response.key_handle())
+        {
+            if u2f
+                .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
+                .is_some()
+            {
+                let mut data = match access.open_no_create(userid)? {
+                    Some(data) => data,
+                    None => bail!("no such challenge"),
+                };
+                let index = data
+                    .get_mut()
+                    .u2f_auths
+                    .iter()
+                    .position(|r| r == challenge)
+                    .ok_or_else(|| format_err!("no such challenge"))?;
+                let entry = data.get_mut().u2f_auths.remove(index);
+                if entry.is_expired(expire_before) {
+                    bail!("no such challenge");
+                }
+                data.save()
+                    .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
+
+                return Ok(());
+            }
+        }
+
+        bail!("u2f verification failed");
+    }
+
+    /// Verify a webauthn response.
+    fn verify_webauthn<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        mut webauthn: Webauthn<WebauthnConfig>,
+        mut response: Value,
+    ) -> Result<(), Error> {
+        let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let challenge = match response
+            .as_object_mut()
+            .ok_or_else(|| format_err!("invalid response, must be a json object"))?
+            .remove("challenge")
+            .ok_or_else(|| format_err!("missing challenge data in response"))?
+        {
+            Value::String(s) => s,
+            _ => bail!("invalid challenge data in response"),
+        };
+
+        let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
+            .map_err(|err| format_err!("invalid webauthn response: {}", err))?;
+
+        let mut data = match access.open_no_create(userid)? {
+            Some(data) => data,
+            None => bail!("no such challenge"),
+        };
+
+        let index = data
+            .get_mut()
+            .webauthn_auths
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let challenge = data.get_mut().webauthn_auths.remove(index);
+        if challenge.is_expired(expire_before) {
+            bail!("no such challenge");
+        }
+
+        // we don't allow re-trying the challenge, so make the removal persistent now:
+        data.save()
+            .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
+
+        match webauthn.authenticate_credential(response, challenge.state)? {
+            Some((_cred, _counter)) => Ok(()),
+            None => bail!("webauthn authentication failed"),
+        }
+    }
+
+    /// Verify a recovery key.
+    ///
+    /// NOTE: If successful, the key will automatically be removed from the list of available
+    /// recovery keys, so the configuration needs to be saved afterwards!
+    fn verify_recovery(&mut self, value: &str) -> Result<(), Error> {
+        if let Some(r) = &mut self.recovery {
+            if r.verify(value)? {
+                return Ok(());
+            }
+        }
+        bail!("recovery verification failed");
+    }
+}
+
+/// A TFA entry for a user.
+///
+/// This simply connects a raw registration to a non optional descriptive text chosen by the user.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TfaEntry<T> {
+    #[serde(flatten)]
+    pub info: TfaInfo,
+
+    /// The actual entry.
+    pub entry: T,
+}
+
+impl<T> TfaEntry<T> {
+    /// Create an entry with a description. The id will be autogenerated.
+    fn new(description: String, entry: T) -> Self {
+        Self {
+            info: TfaInfo {
+                id: Uuid::generate().to_string(),
+                enable: true,
+                description,
+                created: proxmox_time::epoch_i64(),
+            },
+            entry,
+        }
+    }
+
+    /// Create a raw entry from a `TfaInfo` and the corresponding entry data.
+    pub fn from_parts(info: TfaInfo, entry: T) -> Self {
+        Self { info, entry }
+    }
+}
+
+#[cfg_attr(feature = "api-types", api)]
+/// Over the API we only provide this part when querying a user's second factor list.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TfaInfo {
+    /// The id used to reference this entry.
+    pub id: String,
+
+    /// User chosen description for this entry.
+    #[serde(skip_serializing_if = "String::is_empty")]
+    pub description: String,
+
+    /// Creation time of this entry as unix epoch.
+    pub created: i64,
+
+    /// Whether this TFA entry is currently enabled.
+    #[serde(skip_serializing_if = "is_default_tfa_enable")]
+    #[serde(default = "default_tfa_enable")]
+    pub enable: bool,
+}
+
+impl TfaInfo {
+    /// For recovery keys we have a fixed entry.
+    pub fn recovery(created: i64) -> Self {
+        Self {
+            id: "recovery".to_string(),
+            description: String::new(),
+            enable: true,
+            created,
+        }
+    }
+}
+
+const fn default_tfa_enable() -> bool {
+    true
+}
+
+const fn is_default_tfa_enable(v: &bool) -> bool {
+    *v
+}
+
+/// When sending a TFA challenge to the user, we include information about what kind of challenge
+/// the user may perform. If webauthn credentials are available, a webauthn challenge will be
+/// included.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct TfaChallenge {
+    /// True if the user has TOTP devices.
+    #[serde(skip_serializing_if = "bool_is_false", default)]
+    totp: bool,
+
+    /// Whether there are recovery keys available.
+    #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)]
+    recovery: RecoveryState,
+
+    /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    u2f: Option<U2fChallenge>,
+
+    /// If the user has any webauthn credentials registered, this will contain the corresponding
+    /// challenge data.
+    #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
+    webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
+
+    /// True if the user has yubico keys configured.
+    #[serde(skip_serializing_if = "bool_is_false", default)]
+    yubico: bool,
+}
+
+fn bool_is_false(v: &bool) -> bool {
+    !v
+}
+
+/// A user's response to a TFA challenge.
+pub enum TfaResponse {
+    Totp(String),
+    U2f(Value),
+    Webauthn(Value),
+    Recovery(String),
+}
+
+/// This is part of the REST API:
+impl std::str::FromStr for TfaResponse {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        Ok(if let Some(totp) = s.strip_prefix("totp:") {
+            TfaResponse::Totp(totp.to_string())
+        } else if let Some(u2f) = s.strip_prefix("u2f:") {
+            TfaResponse::U2f(serde_json::from_str(u2f)?)
+        } else if let Some(webauthn) = s.strip_prefix("webauthn:") {
+            TfaResponse::Webauthn(serde_json::from_str(webauthn)?)
+        } else if let Some(recovery) = s.strip_prefix("recovery:") {
+            TfaResponse::Recovery(recovery.to_string())
+        } else {
+            bail!("invalid tfa response");
+        })
+    }
+}
+
+/// Active TFA challenges per user, stored in a restricted temporary file on the machine handling
+/// the current user's authentication.
+#[derive(Default, Deserialize, Serialize)]
+pub struct TfaUserChallenges {
+    /// Active u2f registration challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    u2f_registrations: Vec<U2fRegistrationChallenge>,
+
+    /// Active u2f authentication challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    u2f_auths: Vec<U2fChallengeEntry>,
+
+    /// Active webauthn registration challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
+
+    /// Active webauthn authentication challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    webauthn_auths: Vec<WebauthnAuthChallenge>,
+}
+
+/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
+/// time.
+fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+    T: Deserialize<'de> + IsExpired,
+{
+    let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+    deserializer.deserialize_seq(serde_tools::fold(
+        "a challenge entry",
+        |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
+        move |out, reg: T| {
+            if !reg.is_expired(expire_before) {
+                out.push(reg);
+            }
+        },
+    ))
+}
+
+impl TfaUserChallenges {
+    /// Finish a u2f registration. The challenge should correspond to an output of
+    /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
+    /// should come directly from the client.
+    fn u2f_registration_finish(
+        &mut self,
+        u2f: &u2f::U2f,
+        challenge: &str,
+        response: &str,
+    ) -> Result<TfaEntry<u2f::Registration>, Error> {
+        let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let index = self
+            .u2f_registrations
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let reg = &self.u2f_registrations[index];
+        if reg.is_expired(expire_before) {
+            bail!("no such challenge");
+        }
+
+        // the verify call only takes the actual challenge string, so we have to extract it
+        // (u2f::RegistrationChallenge did not always implement Deserialize...)
+        let chobj: Value = serde_json::from_str(challenge)
+            .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
+        let challenge = chobj["challenge"]
+            .as_str()
+            .ok_or_else(|| format_err!("invalid registration challenge"))?;
+
+        let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
+            None => bail!("verification failed"),
+            Some(reg) => {
+                let entry = self.u2f_registrations.remove(index);
+                (reg, entry.description)
+            }
+        };
+
+        // we do not care about the attestation certificates, so don't store them
+        reg.certificate.clear();
+
+        Ok(TfaEntry::new(description, reg))
+    }
+
+    /// Finish a webauthn registration. The challenge should correspond to an output of
+    /// `webauthn_registration_challenge`. The response should come directly from the client.
+    fn webauthn_registration_finish(
+        &mut self,
+        webauthn: Webauthn<WebauthnConfig>,
+        challenge: &str,
+        response: webauthn_rs::proto::RegisterPublicKeyCredential,
+        existing_registrations: &[TfaEntry<WebauthnCredential>],
+    ) -> Result<TfaEntry<WebauthnCredential>, Error> {
+        let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let index = self
+            .webauthn_registrations
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let reg = self.webauthn_registrations.remove(index);
+        if reg.is_expired(expire_before) {
+            bail!("no such challenge");
+        }
+
+        let credential =
+            webauthn.register_credential(response, reg.state, |id| -> Result<bool, ()> {
+                Ok(existing_registrations
+                    .iter()
+                    .any(|cred| cred.entry.cred_id == *id))
+            })?;
+
+        Ok(TfaEntry::new(reg.description, credential))
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs b/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
new file mode 100644
index 0000000..9af2873
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
@@ -0,0 +1,153 @@
+use std::io;
+
+use anyhow::{format_err, Error};
+use openssl::hash::MessageDigest;
+use openssl::pkey::PKey;
+use openssl::sign::Signer;
+use serde::{Deserialize, Serialize};
+
+fn getrandom(mut buffer: &mut [u8]) -> Result<(), io::Error> {
+    while !buffer.is_empty() {
+        let res = unsafe {
+            libc::getrandom(
+                buffer.as_mut_ptr() as *mut libc::c_void,
+                buffer.len() as libc::size_t,
+                0 as libc::c_uint,
+            )
+        };
+
+        if res < 0 {
+            return Err(io::Error::last_os_error());
+        }
+
+        buffer = &mut buffer[(res as usize)..];
+    }
+
+    Ok(())
+}
+
+/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement.
+#[derive(Clone, Deserialize, Serialize)]
+pub struct Recovery {
+    /// "Salt" used for the key HMAC.
+    secret: String,
+
+    /// Recovery key entries are HMACs of the original data. When used up they will become `None`
+    /// since the user is presented an enumerated list of codes, so we know the indices of used and
+    /// unused codes.
+    entries: Vec<Option<String>>,
+
+    /// Creation timestamp as a unix epoch.
+    pub created: i64,
+}
+
+impl Recovery {
+    /// Generate recovery keys and return the recovery entry along with the original string
+    /// entries.
+    pub(super) fn generate() -> Result<(Self, Vec<String>), Error> {
+        let mut secret = [0u8; 8];
+        getrandom(&mut secret)?;
+
+        let mut this = Self {
+            secret: hex::encode(&secret).to_string(),
+            entries: Vec::with_capacity(10),
+            created: proxmox_time::epoch_i64(),
+        };
+
+        let mut original = Vec::new();
+
+        let mut key_data = [0u8; 80]; // 10 keys of 12 bytes
+        getrandom(&mut key_data)?;
+        for b in key_data.chunks(8) {
+            // unwrap: encoding hex bytes to fixed sized arrays
+            let entry = format!(
+                "{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}",
+                b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
+            );
+            this.entries.push(Some(this.hash(entry.as_bytes())?));
+            original.push(entry);
+        }
+
+        Ok((this, original))
+    }
+
+    /// Perform HMAC-SHA256 on the data and return the result as a hex string.
+    fn hash(&self, data: &[u8]) -> Result<String, Error> {
+        let secret = PKey::hmac(self.secret.as_bytes())
+            .map_err(|err| format_err!("error instantiating hmac key: {}", err))?;
+
+        let mut signer = Signer::new(MessageDigest::sha256(), &secret)
+            .map_err(|err| format_err!("error instantiating hmac signer: {}", err))?;
+
+        let hmac = signer
+            .sign_oneshot_to_vec(data)
+            .map_err(|err| format_err!("error calculating hmac: {}", err))?;
+
+        Ok(hex::encode(&hmac))
+    }
+
+    /// Iterator over available keys.
+    fn available(&self) -> impl Iterator<Item = &str> {
+        self.entries.iter().filter_map(Option::as_deref)
+    }
+
+    /// Count the available keys.
+    pub fn count_available(&self) -> usize {
+        self.available().count()
+    }
+
+    /// Convenience serde method to check if either the option is `None` or the content `is_empty`.
+    pub(super) fn option_is_empty(this: &Option<Self>) -> bool {
+        this.as_ref()
+            .map_or(true, |this| this.count_available() == 0)
+    }
+
+    /// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors.
+    pub(super) fn verify(&mut self, key: &str) -> Result<bool, Error> {
+        let hash = self.hash(key.as_bytes())?;
+        for entry in &mut self.entries {
+            if entry.as_ref() == Some(&hash) {
+                *entry = None;
+                return Ok(true);
+            }
+        }
+        Ok(false)
+    }
+}
+
+/// Used to inform the user about the recovery code status.
+///
+/// This contains the available key indices.
+#[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
+pub struct RecoveryState(Vec<usize>);
+
+impl RecoveryState {
+    pub fn is_available(&self) -> bool {
+        !self.is_unavailable()
+    }
+
+    pub fn is_unavailable(&self) -> bool {
+        self.0.is_empty()
+    }
+}
+
+impl From<&Option<Recovery>> for RecoveryState {
+    fn from(r: &Option<Recovery>) -> Self {
+        match r {
+            Some(r) => Self::from(r),
+            None => Self::default(),
+        }
+    }
+}
+
+impl From<&Recovery> for RecoveryState {
+    fn from(r: &Recovery) -> Self {
+        Self(
+            r.entries
+                .iter()
+                .enumerate()
+                .filter_map(|(idx, key)| if key.is_some() { Some(idx) } else { None })
+                .collect(),
+        )
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs b/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
new file mode 100644
index 0000000..1f307a2
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
@@ -0,0 +1,111 @@
+//! Submodule for generic serde helpers.
+//!
+//! FIXME: This should appear in `proxmox-serde`.
+
+use std::fmt;
+use std::marker::PhantomData;
+
+use serde::Deserialize;
+
+/// Helper to abstract away serde details, see [`fold`](fold()).
+pub struct FoldSeqVisitor<T, Out, F, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    F: Fn(&mut Out, T) -> (),
+{
+    init: Option<Init>,
+    closure: F,
+    expecting: &'static str,
+    _ty: PhantomData<T>,
+}
+
+impl<T, Out, F, Init> FoldSeqVisitor<T, Out, F, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    F: Fn(&mut Out, T) -> (),
+{
+    pub fn new(expecting: &'static str, init: Init, closure: F) -> Self {
+        Self {
+            init: Some(init),
+            closure,
+            expecting,
+            _ty: PhantomData,
+        }
+    }
+}
+
+impl<'de, T, Out, F, Init> serde::de::Visitor<'de> for FoldSeqVisitor<T, Out, F, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    F: Fn(&mut Out, T) -> (),
+    T: Deserialize<'de>,
+{
+    type Value = Out;
+
+    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        formatter.write_str(self.expecting)
+    }
+
+    fn visit_seq<A>(mut self, mut seq: A) -> Result<Self::Value, A::Error>
+    where
+        A: serde::de::SeqAccess<'de>,
+    {
+        // unwrap: this is the only place taking out init and we're consuming `self`
+        let mut output = (self.init.take().unwrap())(seq.size_hint());
+
+        while let Some(entry) = seq.next_element::<T>()? {
+            (self.closure)(&mut output, entry);
+        }
+
+        Ok(output)
+    }
+}
+
+/// Create a serde sequence visitor with simple callbacks.
+///
+/// This helps building things such as filters for arrays without having to worry about the serde
+/// implementation details.
+///
+/// Example:
+/// ```
+/// # use serde::Deserialize;
+///
+/// #[derive(Deserialize)]
+/// struct Test {
+///     #[serde(deserialize_with = "stringify_u64")]
+///     foo: Vec<String>,
+/// }
+///
+/// fn stringify_u64<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
+/// where
+///     D: serde::Deserializer<'de>,
+/// {
+///     deserializer.deserialize_seq(proxmox_serde::fold(
+///         "a sequence of integers",
+///         |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
+///         |out, num: u64| {
+///             if num != 4 {
+///                 out.push(num.to_string());
+///             }
+///         },
+///     ))
+/// }
+///
+/// let test: Test =
+///     serde_json::from_str(r#"{"foo":[2, 4, 6]}"#).expect("failed to deserialize test");
+/// assert_eq!(test.foo.len(), 2);
+/// assert_eq!(test.foo[0], "2");
+/// assert_eq!(test.foo[1], "6");
+/// ```
+pub fn fold<'de, T, Out, Init, Fold>(
+    expected: &'static str,
+    init: Init,
+    fold: Fold,
+) -> FoldSeqVisitor<T, Out, Fold, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    Fold: Fn(&mut Out, T) -> (),
+    T: Deserialize<'de>,
+{
+    FoldSeqVisitor::new(expected, init, fold)
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs b/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
new file mode 100644
index 0000000..7b75eb3
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
@@ -0,0 +1,89 @@
+//! u2f configuration and challenge data
+
+use serde::{Deserialize, Serialize};
+
+use proxmox_tfa::u2f;
+
+pub use proxmox_tfa::u2f::{Registration, U2f};
+
+/// The U2F authentication configuration.
+#[derive(Clone, Deserialize, Serialize)]
+pub struct U2fConfig {
+    pub appid: String,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub origin: Option<String>,
+}
+
+/// A u2f registration challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct U2fRegistrationChallenge {
+    /// JSON formatted challenge string.
+    pub challenge: String,
+
+    /// The description chosen by the user for this registration.
+    pub description: String,
+
+    /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+    created: i64,
+}
+
+impl super::IsExpired for U2fRegistrationChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+impl U2fRegistrationChallenge {
+    pub fn new(challenge: String, description: String) -> Self {
+        Self {
+            challenge,
+            description,
+            created: proxmox_time::epoch_i64(),
+        }
+    }
+}
+
+/// Data used for u2f authentication challenges.
+///
+/// This is sent to the client at login time.
+#[derive(Deserialize, Serialize)]
+pub struct U2fChallenge {
+    /// AppID and challenge data.
+    pub(super) challenge: u2f::AuthChallenge,
+
+    /// Available tokens/keys.
+    pub(super) keys: Vec<u2f::RegisteredKey>,
+}
+
+/// The challenge data we need on the server side to verify the challenge:
+/// * It can only be used once.
+/// * It can expire.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct U2fChallengeEntry {
+    challenge: u2f::AuthChallenge,
+    created: i64,
+}
+
+impl U2fChallengeEntry {
+    pub fn new(challenge: &U2fChallenge) -> Self {
+        Self {
+            challenge: challenge.challenge.clone(),
+            created: proxmox_time::epoch_i64(),
+        }
+    }
+}
+
+impl super::IsExpired for U2fChallengeEntry {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+impl PartialEq<u2f::AuthChallenge> for U2fChallengeEntry {
+    fn eq(&self, other: &u2f::AuthChallenge) -> bool {
+        self.challenge.challenge == other.challenge && self.challenge.app_id == other.app_id
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs b/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs
new file mode 100644
index 0000000..8d98ed4
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs
@@ -0,0 +1,118 @@
+//! Webauthn configuration and challenge data.
+
+use serde::{Deserialize, Serialize};
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
+use super::IsExpired;
+
+#[cfg_attr(feature = "api-types", api)]
+/// Server side webauthn server configuration.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnConfig {
+    /// Relying party name. Any text identifier.
+    ///
+    /// Changing this *may* break existing credentials.
+    pub rp: String,
+
+    /// Site origin. Must be a `https://` URL (or `http://localhost`). Should contain the address
+    /// users type in their browsers to access the web interface.
+    ///
+    /// Changing this *may* break existing credentials.
+    pub origin: String,
+
+    /// Relying part ID. Must be the domain name without protocol, port or location.
+    ///
+    /// Changing this *will* break existing credentials.
+    pub id: String,
+}
+
+/// For now we just implement this on the configuration this way.
+///
+/// Note that we may consider changing this so `get_origin` returns the `Host:` header provided by
+/// the connecting client.
+impl webauthn_rs::WebauthnConfig for WebauthnConfig {
+    fn get_relying_party_name(&self) -> String {
+        self.rp.clone()
+    }
+
+    fn get_origin(&self) -> &String {
+        &self.origin
+    }
+
+    fn get_relying_party_id(&self) -> String {
+        self.id.clone()
+    }
+}
+
+/// A webauthn registration challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnRegistrationChallenge {
+    /// Server side registration state data.
+    pub(super) state: webauthn_rs::RegistrationState,
+
+    /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't
+    /// make this public.
+    pub(super) challenge: String,
+
+    /// The description chosen by the user for this registration.
+    pub(super) description: String,
+
+    /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+    created: i64,
+}
+
+impl WebauthnRegistrationChallenge {
+    pub fn new(
+        state: webauthn_rs::RegistrationState,
+        challenge: String,
+        description: String,
+    ) -> Self {
+        Self {
+            state,
+            challenge,
+            description,
+            created: proxmox_time::epoch_i64(),
+        }
+    }
+}
+
+impl IsExpired for WebauthnRegistrationChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+/// A webauthn authentication challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnAuthChallenge {
+    /// Server side authentication state.
+    pub(super) state: webauthn_rs::AuthenticationState,
+
+    /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate
+    /// doesn't make this public.
+    pub(super) challenge: String,
+
+    /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+    created: i64,
+}
+
+impl WebauthnAuthChallenge {
+    pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self {
+        Self {
+            state,
+            challenge,
+            created: proxmox_time::epoch_i64(),
+        }
+    }
+}
+
+impl IsExpired for WebauthnAuthChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH proxmox-perl-rs 5/6] build fix: pmg-rs is not here yet
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (3 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 6/6] Add some dev tips to a README Wolfgang Bumiller
                   ` (27 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 Makefile | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/Makefile b/Makefile
index b6cb8bb..a01a718 100644
--- a/Makefile
+++ b/Makefile
@@ -27,15 +27,15 @@ build:
 	mkdir build
 	echo system >build/rust-toolchain
 	cp -a ./perl-* ./build/
-	cp -a ./pve-rs ./pmg-rs ./build
+	cp -a ./pve-rs ./build
 
 pve-deb: build
 	cd ./build/pve-rs && dpkg-buildpackage -b -uc -us
 	touch $@
 
-pmg-deb: build
-	cd ./build/pmg-rs && dpkg-buildpackage -b -uc -us
-	touch $@
+# pmg-deb: build
+# 	cd ./build/pmg-rs && dpkg-buildpackage -b -uc -us
+# 	touch $@
 
 .PHONY: clean
 clean:
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH proxmox-perl-rs 6/6] Add some dev tips to a README
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (4 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 5/6] build fix: pmg-rs is not here yet Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 01/10] use rust parser for TFA config Wolfgang Bumiller
                   ` (26 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 README.md | 13 +++++++++++++
 1 file changed, 13 insertions(+)
 create mode 100644 README.md

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d2dcb94
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+# Hints for development:
+
+With the current perlmod, the `.pm` files don't actually change anymore, since the exported method
+setup is now handled by the bootstrap rust-function generated by perlmod, so for quicker debugging,
+you can just keep the installed `.pm` files from the package and simply link the library to the
+debug one like so:
+
+NOTE: You may need to adapt the perl version number in this path:
+```
+# ln -sf $PWD/target/debug/libpve_rs.so /usr/lib/x86_64-linux-gnu/perl5/5.32/auto/libpve_rs.so
+```
+
+Then just restart pvedaemon/pveproxy after running `make pve`.
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 01/10] use rust parser for TFA config
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (5 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 6/6] Add some dev tips to a README Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 02/10] update read_user_tfa_type call Wolfgang Bumiller
                   ` (25 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/AccessControl.pm | 50 +++++++++-------------------------------
 1 file changed, 11 insertions(+), 39 deletions(-)

diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index 347c2a8..2fa2695 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -19,6 +19,8 @@ use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print)
 use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
 use PVE::JSONSchema qw(register_standard_option get_standard_option);
 
+use PVE::RS::TFA;
+
 use PVE::Auth::Plugin;
 use PVE::Auth::AD;
 use PVE::Auth::LDAP;
@@ -1353,33 +1355,21 @@ sub write_user_config {
     return $data;
 }
 
-# The TFA configuration in priv/tfa.cfg format contains one line per user of
-# the form:
-#     USER:TYPE:DATA
-# DATA is a base64 encoded json string and its format depends on the type.
+# Creates a `PVE::RS::TFA` instance from the raw config data.
+# Its contained hash will also support the legacy functionality.
 sub parse_priv_tfa_config {
     my ($filename, $raw) = @_;
 
-    my $users = {};
-    my $cfg = { users => $users };
-
     $raw = '' if !defined($raw);
-    while ($raw =~ /^\s*(.+?)\s*$/gm) {
-	my $line = $1;
-	my ($user, $type, $data) = split(/:/, $line, 3);
+    my $cfg = PVE::RS::TFA->new($raw);
 
+    # Purge invalid users:
+    foreach my $user ($cfg->users()->@*) {
 	my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
 	if (!$realm) {
 	    warn "user tfa config - ignore user '$user' - invalid user name\n";
-	    next;
+	    $cfg->remove_user($user);
 	}
-
-	$data = decode_json(decode_base64($data));
-
-	$users->{$user} = {
-	    type => $type,
-	    data => $data,
-	};
     }
 
     return $cfg;
@@ -1388,27 +1378,9 @@ sub parse_priv_tfa_config {
 sub write_priv_tfa_config {
     my ($filename, $cfg) = @_;
 
-    my $output = '';
-
-    my $users = $cfg->{users};
-    foreach my $user (sort keys %$users) {
-	my $info = $users->{$user};
-	next if !%$info; # skip empty entries
-
-	$info = {%$info}; # copy to verify contents:
-
-	my $type = delete $info->{type};
-	my $data = delete $info->{data};
-
-	if (my @keys = keys %$info) {
-	    die "invalid keys in TFA config for user $user: " . join(', ', @keys) . "\n";
-	}
-
-	$data = encode_base64(encode_json($data), '');
-	$output .= "${user}:${type}:${data}\n";
-    }
-
-    return $output;
+    # FIXME: Only allow this if the complete cluster has been upgraded to understand the json
+    # config format.
+    return $cfg->write();
 }
 
 sub roles {
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 02/10] update read_user_tfa_type call
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (6 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 01/10] use rust parser for TFA config Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 03/10] use PBS-like auth api call flow Wolfgang Bumiller
                   ` (24 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/User.pm | 41 +++++++++++++++++++++++++++++++++++------
 1 file changed, 35 insertions(+), 6 deletions(-)

diff --git a/src/PVE/API2/User.pm b/src/PVE/API2/User.pm
index 8893d03..3d4d4e0 100644
--- a/src/PVE/API2/User.pm
+++ b/src/PVE/API2/User.pm
@@ -485,6 +485,12 @@ __PACKAGE__->register_method ({
 	additionalProperties => 0,
 	properties => {
 	    userid => get_standard_option('userid-completed'),
+	    multiple => {
+		type => 'boolean',
+		description => 'Request all entries as an array.',
+		optional => 1,
+		default => 0,
+	    },
 	},
     },
     returns => {
@@ -499,9 +505,23 @@ __PACKAGE__->register_method ({
 	    user => {
 		type => 'string',
 		enum => [qw(oath u2f)],
-		description => "The type of TFA the user has set, if any.",
+		description =>
+		    "The type of TFA the user has set, if any."
+		    . " Only set if 'multiple' was not passed.",
 		optional => 1,
 	    },
+	    types => {
+		type => 'array',
+		description =>
+		    "Array of the user configured TFA types, if any."
+		    . " Only available if 'multiple' was not passed.",
+		optional => 1,
+		items => {
+		    type => 'string',
+		    enum => [qw(totp u2f yubico webauthn recovedry)],
+		    description => 'A TFA type.',
+		},
+	    },
 	},
 	type => "object"
     },
@@ -514,15 +534,24 @@ __PACKAGE__->register_method ({
 	my $realm_cfg = $domain_cfg->{ids}->{$realm};
 	die "auth domain '$realm' does not exist\n" if !$realm_cfg;
 
+	my $res = {};
 	my $realm_tfa = {};
 	$realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_cfg->{tfa}) if $realm_cfg->{tfa};
+	$res->{realm} = $realm_tfa->{type} if $realm_tfa->{type};
 
 	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-	my $tfa = $tfa_cfg->{users}->{$username};
-
-	my $res = {};
-	$res->{realm} = $realm_tfa->{type} if $realm_tfa->{type};
-	$res->{user} = $tfa->{type} if $tfa->{type};
+	if ($param->{multiple}) {
+	    my $tfa = $tfa_cfg->get_user($username);
+	    my $user = [];
+	    foreach my $type (keys %$tfa) {
+		next if !scalar($tfa->{$type}->@*);
+		push @$user, $type;
+	    }
+	    $res->{user} = $user;
+	} else {
+	    my $tfa = $tfa_cfg->{users}->{$username};
+	    $res->{user} = $tfa->{type} if $tfa->{type};
+	}
 	return $res;
     }});
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 03/10] use PBS-like auth api call flow
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (7 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 02/10] update read_user_tfa_type call Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 04/10] handle yubico authentication in new path Wolfgang Bumiller
                   ` (23 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

The main difference here is that we have no separate api
path for verification. Instead, the ticket api call gets an
optional 'tfa-challenge' parameter.

This is opt-in: old pve-manager UI with new
pve-access-control will still work as expected, but users
won't be able to *update* their TFA settings.

Since the new tfa config parser will build a compatible
in-perl tfa config object as well, the old authentication
code is left unchanged for compatibility and will be removed
with pve-8, where the `new-format` parameter in the ticket
call will change its default to `1`.

The `change_tfa` call will simply die in this commit. It is
removed later when adding the pbs-style TFA API calls.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/AccessControl.pm |  79 ++++++++++++++----
 src/PVE/AccessControl.pm      | 153 +++++++++++++++++++++++++++++-----
 2 files changed, 199 insertions(+), 33 deletions(-)

diff --git a/src/PVE/API2/AccessControl.pm b/src/PVE/API2/AccessControl.pm
index 6dec66c..8fa3606 100644
--- a/src/PVE/API2/AccessControl.pm
+++ b/src/PVE/API2/AccessControl.pm
@@ -105,8 +105,8 @@ __PACKAGE__->register_method ({
     }});
 
 
-my $verify_auth = sub {
-    my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_;
+my sub verify_auth : prototype($$$$$$$) {
+    my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs, $new_format) = @_;
 
     my $normpath = PVE::AccessControl::normalize_path($path);
 
@@ -117,7 +117,12 @@ my $verify_auth = sub {
     } elsif (PVE::AccessControl::verify_vnc_ticket($pw_or_ticket, $username, $normpath, 1)) {
 	# valid vnc ticket
     } else {
-	$username = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+	$username = PVE::AccessControl::authenticate_user(
+	    $username,
+	    $pw_or_ticket,
+	    $otp,
+	    $new_format,
+	);
     }
 
     my $privlist = [ PVE::Tools::split_list($privs) ];
@@ -128,22 +133,45 @@ my $verify_auth = sub {
     return { username => $username };
 };
 
-my $create_ticket = sub {
-    my ($rpcenv, $username, $pw_or_ticket, $otp) = @_;
+my sub create_ticket_do : prototype($$$$$$) {
+    my ($rpcenv, $username, $pw_or_ticket, $otp, $new_format, $tfa_challenge) = @_;
+
+    die "TFA response should be in 'password', not 'otp' when 'tfa-challenge' is set\n"
+	if defined($otp) && defined($tfa_challenge);
+
+    my ($ticketuser, undef, $tfa_info);
+    if (!defined($tfa_challenge)) {
+	# We only verify this ticket if we're not responding to a TFA challenge, as in that case
+	# it is a TFA-data ticket and will be verified by `authenticate_user`.
+
+	($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1);
+    }
 
-    my ($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1);
     if (defined($ticketuser) && ($ticketuser eq 'root@pam' || $ticketuser eq $username)) {
 	if (defined($tfa_info)) {
 	    die "incomplete ticket\n";
 	}
 	# valid ticket. Note: root@pam can create tickets for other users
     } else {
-	($username, $tfa_info) = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+	($username, $tfa_info) = PVE::AccessControl::authenticate_user(
+	    $username,
+	    $pw_or_ticket,
+	    $otp,
+	    $new_format,
+	    $tfa_challenge,
+	);
     }
 
     my %extra;
     my $ticket_data = $username;
-    if (defined($tfa_info)) {
+    my $aad;
+    if ($new_format) {
+	if (defined($tfa_info)) {
+	    $extra{NeedTFA} = 1;
+	    $ticket_data = "!tfa!$tfa_info";
+	    $aad = $username;
+	}
+    } elsif (defined($tfa_info)) {
 	$extra{NeedTFA} = 1;
 	if ($tfa_info->{type} eq 'u2f') {
 	    my $u2finfo = $tfa_info->{data};
@@ -159,7 +187,7 @@ my $create_ticket = sub {
 	}
     }
 
-    my $ticket = PVE::AccessControl::assemble_ticket($ticket_data);
+    my $ticket = PVE::AccessControl::assemble_ticket($ticket_data, $aad);
     my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username);
 
     return {
@@ -230,6 +258,20 @@ __PACKAGE__->register_method ({
 		optional => 1,
 		maxLength => 64,
 	    },
+	    'new-format' => {
+		type => 'boolean',
+		description =>
+		    'With webauthn the format of half-authenticated tickts changed.'
+		    .' New clients should pass 1 here and not worry about the old format.'
+		    .' The old format is deprecated and will be retired with PVE-8.0',
+		optional => 1,
+		default => 0,
+	    },
+	    'tfa-challenge' => {
+		type => 'string',
+                description => "The signed TFA challenge string the user wants to respond to.",
+		optional => 1,
+	    },
 	}
     },
     returns => {
@@ -257,10 +299,17 @@ __PACKAGE__->register_method ({
 	    $rpcenv->check_user_enabled($username);
 
 	    if ($param->{path} && $param->{privs}) {
-		$res = &$verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
-				     $param->{path}, $param->{privs});
+		$res = verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
+				   $param->{path}, $param->{privs}, $param->{'new-format'});
 	    } else {
-		$res = &$create_ticket($rpcenv, $username, $param->{password}, $param->{otp});
+		$res = create_ticket_do(
+		    $rpcenv,
+		    $username,
+		    $param->{password},
+		    $param->{otp},
+		    $param->{'new-format'},
+		    $param->{'tfa-challenge'},
+		);
 	    }
 	};
 	if (my $err = $@) {
@@ -476,6 +525,8 @@ __PACKAGE__->register_method ({
     code => sub {
 	my ($param) = @_;
 
+	die "TODO!\n";
+
 	my $rpcenv = PVE::RPCEnvironment::get();
 	my $authuser = $rpcenv->get_user();
 
@@ -528,7 +579,7 @@ __PACKAGE__->register_method ({
 	    raise_param_exc({ 'response' => "confirm action requires the 'response' parameter to be set" })
 		if !defined($response);
 
-	    my ($type, $u2fdata) = PVE::AccessControl::user_get_tfa($userid, $realm);
+	    my ($type, $u2fdata) = PVE::AccessControl::user_get_tfa($userid, $realm, 'FIXME');
 	    raise("no u2f data available")
 		if (!defined($type) || $type ne 'u2f');
 
@@ -580,7 +631,7 @@ __PACKAGE__->register_method({
 	my $authuser = $rpcenv->get_user();
 	my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
 
-	my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm);
+	my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm, 0);
 	if (!defined($tfa_type)) {
 	    raise('no u2f data available');
 	}
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index 2fa2695..d61d7f4 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -338,16 +338,23 @@ my $get_ticket_age_range = sub {
     return ($min, $max);
 };
 
-sub assemble_ticket {
-    my ($data) = @_;
+sub assemble_ticket : prototype($;$) {
+    my ($data, $aad) = @_;
 
     my $rsa_priv = get_privkey();
 
-    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data);
+    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data, $aad);
 }
 
-sub verify_ticket {
-    my ($ticket, $noerr) = @_;
+# Returns the username, "age" and tfa info.
+#
+# Note that for the new-style outh, tfa info is never set, as it only uses the `/ticket` api call
+# via the new 'tfa-challenge' parameter, so this part can go with PVE-8.
+#
+# New-style auth still uses this function, but sets `$tfa_ticket` to true when validating the tfa
+# ticket.
+sub verify_ticket : prototype($;$$) {
+    my ($ticket, $noerr, $tfa_ticket_aad) = @_;
 
     my $now = time();
 
@@ -361,7 +368,7 @@ sub verify_ticket {
 	return undef if !defined($min);
 
 	return PVE::Ticket::verify_rsa_ticket(
-	    $rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
+	    $rsa_pub, 'PVE', $ticket, $tfa_ticket_aad, $min, $max, 1);
     };
 
     my ($data, $age) = $check->();
@@ -382,7 +389,21 @@ sub verify_ticket {
 	return $auth_failure->();
     }
 
+    if ($tfa_ticket_aad) {
+	# We're validating a ticket-call's 'tfa-challenge' parameter, so just return its data.
+	if ($data =~ /^!tfa!(.*)$/) {
+	    return $1;
+	}
+	die "bad ticket\n";
+    }
+
     my ($username, $tfa_info);
+    if ($data =~ /^!tfa!(.*)$/) {
+	# PBS style half-authenticated ticket, contains a json string form of a `TfaChallenge`
+	# object.
+	# This type of ticket does not contain the user name.
+	return { type => 'new', data => $1 };
+    }
     if ($data =~ m{^u2f!([^!]+)!([0-9a-zA-Z/.=_\-+]+)$}) {
 	# Ticket for u2f-users:
 	($username, my $challenge) = ($1, $2);
@@ -623,8 +644,8 @@ sub verify_one_time_pw {
 
 # password should be utf8 encoded
 # Note: some plugins delay/sleep if auth fails
-sub authenticate_user {
-    my ($username, $password, $otp) = @_;
+sub authenticate_user : prototype($$$$;$) {
+    my ($username, $password, $otp, $new_format, $tfa_challenge) = @_;
 
     die "no username specified\n" if !$username;
 
@@ -641,9 +662,28 @@ sub authenticate_user {
     my $cfg = $domain_cfg->{ids}->{$realm};
     die "auth domain '$realm' does not exist\n" if !$cfg;
     my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
+
+    if ($tfa_challenge) {
+	# This is the 2nd factor, use the password for the OTP response.
+	my $tfa_challenge = authenticate_2nd_new($username, $realm, $password, $tfa_challenge);
+	return wantarray ? ($username, $tfa_challenge) : $username;
+    }
+
     $plugin->authenticate_user($cfg, $realm, $ruid, $password);
 
-    my ($type, $tfa_data) = user_get_tfa($username, $realm);
+    if ($new_format) {
+	# This is the first factor with an optional immediate 2nd factor for TOTP:
+	my $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, $tfa_challenge);
+	return wantarray ? ($username, $tfa_challenge) : $username;
+    } else {
+	return authenticate_2nd_old($username, $realm, $otp);
+    }
+}
+
+sub authenticate_2nd_old : prototype($$$) {
+    my ($username, $realm, $otp) = @_;
+
+    my ($type, $tfa_data) = user_get_tfa($username, $realm, 0);
     if ($type) {
 	if ($type eq 'u2f') {
 	    # Note that if the user did not manage to complete the initial u2f registration
@@ -671,6 +711,77 @@ sub authenticate_user {
     return wantarray ? ($username, $tfa_data) : $username;
 }
 
+# Returns a tfa challenge or undef.
+sub authenticate_2nd_new : prototype($$$$) {
+    my ($username, $realm, $otp, $tfa_challenge) = @_;
+
+    return lock_tfa_config(sub {
+	my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm, 1);
+
+	if (!defined($tfa_cfg)) {
+	    return undef;
+	}
+
+	my $realm_type = $realm_tfa && $realm_tfa->{type};
+	if (defined($realm_type) && $realm_type eq 'yubico') {
+	    $tfa_cfg->set_yubico_config({
+		id => $realm_tfa->{id},
+		key => $realm_tfa->{key},
+		url => $realm_tfa->{url},
+	    });
+	}
+
+	configure_u2f_and_wa($tfa_cfg);
+
+	my $must_save = 0;
+	if (defined($tfa_challenge)) {
+	    $tfa_challenge = verify_ticket($tfa_challenge, 0, $username);
+	    $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $otp);
+	    $tfa_challenge = undef;
+	} else {
+	    $tfa_challenge = $tfa_cfg->authentication_challenge($username);
+	    if (defined($otp)) {
+		if (defined($tfa_challenge)) {
+		    $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $otp);
+		} else {
+		    die "no such challenge\n";
+		}
+	    }
+	}
+
+	if ($must_save) {
+	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+	}
+
+	return $tfa_challenge;
+    });
+}
+
+sub configure_u2f_and_wa : prototype($) {
+    my ($tfa_cfg) = @_;
+
+    my $dc = cfs_read_file('datacenter.cfg');
+    if (my $u2f = $dc->{u2f}) {
+	my $origin = $u2f->{origin};
+	if (!defined($origin)) {
+	    my $rpcenv = PVE::RPCEnvironment::get();
+	    $origin = $rpcenv->get_request_host(1);
+	    if ($origin) {
+		$origin = "https://$origin";
+	    } else {
+		die "failed to figure out u2f origin\n";
+	    }
+	}
+	$tfa_cfg->set_u2f_config({
+	    origin => $origin,
+	    appid => $u2f->{appid},
+	});
+    }
+    if (my $wa = $dc->{webauthn}) {
+	$tfa_cfg->set_webauthn_config($wa);
+    }
+}
+
 sub domain_set_password {
     my ($realm, $username, $password) = @_;
 
@@ -1630,8 +1741,8 @@ sub user_set_tfa {
     cfs_write_file('user.cfg', $user_cfg) if defined($user);
 }
 
-sub user_get_tfa {
-    my ($username, $realm) = @_;
+sub user_get_tfa : prototype($$$) {
+    my ($username, $realm, $new_format) = @_;
 
     my $user_cfg = cfs_read_file('user.cfg');
     my $user = $user_cfg->{users}->{$username}
@@ -1662,16 +1773,20 @@ sub user_get_tfa {
 	});
     } else {
 	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-	my $tfa = $tfa_cfg->{users}->{$username};
-	return if !$tfa; # should not happen (user.cfg wasn't cleaned up?)
+	if ($new_format) {
+	    return ($tfa_cfg, $realm_tfa);
+	} else {
+	    my $tfa = $tfa_cfg->{users}->{$username};
+	    return if !$tfa; # should not happen (user.cfg wasn't cleaned up?)
 
-	if ($realm_tfa) {
-	    # if the realm has a tfa setting we need to verify the type:
-	    die "auth domain '$realm' and user have mismatching TFA settings\n"
-		if $realm_tfa && $realm_tfa->{type} ne $tfa->{type};
-	}
+	    if ($realm_tfa) {
+		# if the realm has a tfa setting we need to verify the type:
+		die "auth domain '$realm' and user have mismatching TFA settings\n"
+		    if $realm_tfa && $realm_tfa->{type} ne $tfa->{type};
+	    }
 
-	return ($tfa->{type}, $tfa->{data});
+	    return ($tfa->{type}, $tfa->{data});
+	}
     }
 }
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 04/10] handle yubico authentication in new path
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (8 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 03/10] use PBS-like auth api call flow Wolfgang Bumiller
@ 2021-11-09 11:26 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 05/10] move TFA api path into its own module Wolfgang Bumiller
                   ` (22 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:26 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/AccessControl.pm | 60 ++++++++++++++++++++++++++++++++++++----
 1 file changed, 54 insertions(+), 6 deletions(-)

diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index d61d7f4..29d22ac 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -624,6 +624,7 @@ sub check_token_exist {
     return undef;
 }
 
+# deprecated
 sub verify_one_time_pw {
     my ($type, $username, $keys, $tfa_cfg, $otp) = @_;
 
@@ -715,7 +716,7 @@ sub authenticate_2nd_old : prototype($$$) {
 sub authenticate_2nd_new : prototype($$$$) {
     my ($username, $realm, $otp, $tfa_challenge) = @_;
 
-    return lock_tfa_config(sub {
+    my $result = lock_tfa_config(sub {
 	my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm, 1);
 
 	if (!defined($tfa_cfg)) {
@@ -724,11 +725,28 @@ sub authenticate_2nd_new : prototype($$$$) {
 
 	my $realm_type = $realm_tfa && $realm_tfa->{type};
 	if (defined($realm_type) && $realm_type eq 'yubico') {
-	    $tfa_cfg->set_yubico_config({
-		id => $realm_tfa->{id},
-		key => $realm_tfa->{key},
-		url => $realm_tfa->{url},
-	    });
+	    # Yubico auth will not be supported in rust for now...
+	    if (!defined($tfa_challenge)) {
+		my $challenge = { yubico => JSON::true };
+		# Even with yubico auth we do allow recovery keys to be used:
+		if (my $recovery = $tfa_cfg->recovery_state($username)) {
+		    $challenge->{recovery} = $recovery;
+		}
+		return to_json($challenge);
+	    }
+
+	    if ($otp =~ /^yubico:(.*)$/) {
+		$otp = $1;
+		# Defer to after unlocking the TFA config:
+		return sub {
+		    authenticate_yubico_new($tfa_cfg, $username, $realm_tfa, $tfa_challenge, $otp);
+		};
+	    }
+
+	    # Beside the realm configured auth we only allow recovery keys:
+	    if ($otp !~ /^recovery:/) {
+		die "realm requires yubico authentication\n";
+	    }
 	}
 
 	configure_u2f_and_wa($tfa_cfg);
@@ -755,6 +773,36 @@ sub authenticate_2nd_new : prototype($$$$) {
 
 	return $tfa_challenge;
     });
+
+    # Yubico auth returns the authentication sub:
+    if (ref($result) eq 'CODE') {
+	$result = $result->();
+    }
+
+    return $result;
+}
+
+sub authenticate_yubico_new : prototype($$$) {
+    my ($tfa_cfg, $username, $realm, $tfa_challenge, $otp) = @_;
+
+    $tfa_challenge = verify_ticket($tfa_challenge, 0, $username);
+    $tfa_challenge = from_json($tfa_challenge);
+
+    if (!$tfa_challenge->{yubico}) {
+	die "no such challenge\n";
+    }
+
+    my $keys = $tfa_cfg->get_yubico_keys($username);
+    die "no keys configured\n" if !defined($keys) || !length($keys);
+
+    # Defer to after unlocking the TFA config:
+
+    # fixme: proxy support?
+    my $proxy;
+    PVE::OTP::yubico_verify_otp($otp, $keys, $realm->{url}, $realm->{id}, $realm->{key}, $proxy);
+
+    # return `undef` to clear the tfa challenge.
+    return undef;
 }
 
 sub configure_u2f_and_wa : prototype($) {
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 05/10] move TFA api path into its own module
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (9 preceding siblings ...)
  2021-11-09 11:26 ` [pve-devel] [PATCH access-control 04/10] handle yubico authentication in new path Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 06/10] add pbs-style TFA API implementation Wolfgang Bumiller
                   ` (21 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

and remove old modification api

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/AccessControl.pm | 211 +-----------------------------
 src/PVE/API2/Makefile         |   1 +
 src/PVE/API2/TFA.pm           | 239 ++++++++++++++++++++++++++++++++++
 src/PVE/AccessControl.pm      |  69 ----------
 src/PVE/CLI/pveum.pm          |   3 +-
 5 files changed, 248 insertions(+), 275 deletions(-)
 create mode 100644 src/PVE/API2/TFA.pm

diff --git a/src/PVE/API2/AccessControl.pm b/src/PVE/API2/AccessControl.pm
index 8fa3606..5d78c6f 100644
--- a/src/PVE/API2/AccessControl.pm
+++ b/src/PVE/API2/AccessControl.pm
@@ -20,6 +20,7 @@ use PVE::API2::Group;
 use PVE::API2::Role;
 use PVE::API2::ACL;
 use PVE::API2::OpenId;
+use PVE::API2::TFA;
 use PVE::Auth::Plugin;
 use PVE::OTP;
 
@@ -61,6 +62,11 @@ __PACKAGE__->register_method ({
     path => 'openid',
 });
 
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::TFA",
+    path => 'tfa',
+});
+
 __PACKAGE__->register_method ({
     name => 'index',
     path => '',
@@ -464,211 +470,6 @@ sub verify_user_tfa_config {
     PVE::OTP::oath_verify_otp($value, $secret, $step, $digits);
 }
 
-__PACKAGE__->register_method ({
-    name => 'change_tfa',
-    path => 'tfa',
-    method => 'PUT',
-    permissions => {
-	description => 'A user can change their own u2f or totp token.',
-	check => [ 'or',
-		   ['userid-param', 'self'],
-		   [ 'and',
-		     [ 'userid-param', 'Realm.AllocateUser'],
-		     [ 'userid-group', ['User.Modify']]
-		   ]
-	    ],
-    },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
-    description => "Change user u2f authentication.",
-    parameters => {
-	additionalProperties => 0,
-	properties => {
-	    userid => get_standard_option('userid', {
-		completion => \&PVE::AccessControl::complete_username,
-	    }),
-	    password => {
-		optional => 1, # Only required if not root@pam
-		description => "The current password.",
-		type => 'string',
-		minLength => 5,
-		maxLength => 64,
-	    },
-	    action => {
-		description => 'The action to perform',
-		type => 'string',
-		enum => [qw(delete new confirm)],
-	    },
-	    response => {
-		optional => 1,
-		description =>
-		    'Either the the response to the current u2f registration challenge,'
-		    .' or, when adding TOTP, the currently valid TOTP value.',
-		type => 'string',
-	    },
-	    key => {
-		optional => 1,
-		description => 'When adding TOTP, the shared secret value.',
-		type => 'string',
-		format => 'pve-tfa-secret',
-	    },
-	    config => {
-		optional => 1,
-		description => 'A TFA configuration. This must currently be of type TOTP of not set at all.',
-		type => 'string',
-		format => 'pve-tfa-config',
-		maxLength => 128,
-	    },
-	}
-    },
-    returns => { type => 'object' },
-    code => sub {
-	my ($param) = @_;
-
-	die "TODO!\n";
-
-	my $rpcenv = PVE::RPCEnvironment::get();
-	my $authuser = $rpcenv->get_user();
-
-	my $action = delete $param->{action};
-	my $response = delete $param->{response};
-	my $password = delete($param->{password}) // '';
-	my $key = delete($param->{key});
-	my $config = delete($param->{config});
-
-	my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
-	$rpcenv->check_user_exist($userid);
-
-	# Only root may modify root
-	raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
-
-	# Regular users need to confirm their password to change u2f settings.
-	if ($authuser ne 'root@pam') {
-	    raise_param_exc({ 'password' => 'password is required to modify u2f data' })
-		if !defined($password);
-	    my $domain_cfg = cfs_read_file('domains.cfg');
-	    my $cfg = $domain_cfg->{ids}->{$realm};
-	    die "auth domain '$realm' does not exist\n" if !$cfg;
-	    my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
-	    $plugin->authenticate_user($cfg, $realm, $ruid, $password);
-	}
-
-	if ($action eq 'delete') {
-	    PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef);
-	    PVE::Cluster::log_msg('info', $authuser, "deleted u2f data for user '$userid'");
-	} elsif ($action eq 'new') {
-	    if (defined($config)) {
-		$config = PVE::Auth::Plugin::parse_tfa_config($config);
-		my $type = delete($config->{type});
-		my $tfa_cfg = {
-		    keys => $key,
-		    config => $config,
-		};
-		verify_user_tfa_config($type, $tfa_cfg, $response);
-		PVE::AccessControl::user_set_tfa($userid, $realm, $type, $tfa_cfg);
-	    } else {
-		# The default is U2F:
-		my $u2f = get_u2f_instance($rpcenv);
-		my $challenge = $u2f->registration_challenge()
-		    or raise("failed to get u2f challenge");
-		$challenge = decode_json($challenge);
-		PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', $challenge);
-		return $challenge;
-	    }
-	} elsif ($action eq 'confirm') {
-	    raise_param_exc({ 'response' => "confirm action requires the 'response' parameter to be set" })
-		if !defined($response);
-
-	    my ($type, $u2fdata) = PVE::AccessControl::user_get_tfa($userid, $realm, 'FIXME');
-	    raise("no u2f data available")
-		if (!defined($type) || $type ne 'u2f');
-
-	    my $challenge = $u2fdata->{challenge}
-		or raise("no active challenge");
-
-	    my $u2f = get_u2f_instance($rpcenv);
-	    $u2f->set_challenge($challenge);
-	    my ($keyHandle, $publicKey) = $u2f->registration_verify($response);
-	    PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', {
-		keyHandle => $keyHandle,
-		publicKey => $publicKey, # already base64 encoded
-	    });
-	} else {
-	    die "invalid action: $action\n";
-	}
-
-	return {};
-    }});
-
-__PACKAGE__->register_method({
-    name => 'verify_tfa',
-    path => 'tfa',
-    method => 'POST',
-    permissions => { user => 'all' },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to access TFA information
-    description => 'Finish a u2f challenge.',
-    parameters => {
-	additionalProperties => 0,
-	properties => {
-	    response => {
-		type => 'string',
-		description => 'The response to the current authentication challenge.',
-	    },
-	}
-    },
-    returns => {
-	type => 'object',
-	properties => {
-	    ticket => { type => 'string' },
-	    # cap
-	}
-    },
-    code => sub {
-	my ($param) = @_;
-
-	my $rpcenv = PVE::RPCEnvironment::get();
-	my $authuser = $rpcenv->get_user();
-	my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
-
-	my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm, 0);
-	if (!defined($tfa_type)) {
-	    raise('no u2f data available');
-	}
-
-	eval {
-	    if ($tfa_type eq 'u2f') {
-		my $challenge = $rpcenv->get_u2f_challenge()
-		   or raise('no active challenge');
-
-		my $keyHandle = $tfa_data->{keyHandle};
-		my $publicKey = $tfa_data->{publicKey};
-		raise("incomplete u2f setup")
-		    if !defined($keyHandle) || !defined($publicKey);
-
-		my $u2f = get_u2f_instance($rpcenv, $publicKey, $keyHandle);
-		$u2f->set_challenge($challenge);
-
-		my ($counter, $present) = $u2f->auth_verify($param->{response});
-		# Do we want to do anything with these?
-	    } else {
-		# sanity check before handing off to the verification code:
-		my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
-		my $config = $tfa_data->{config} or die "bad tfa entry\n";
-		PVE::AccessControl::verify_one_time_pw($tfa_type, $authuser, $keys, $config, $param->{response});
-	    }
-	};
-	if (my $err = $@) {
-	    my $clientip = $rpcenv->get_client_ip() || '';
-	    syslog('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
-	    die PVE::Exception->new("authentication failure\n", code => 401);
-	}
-
-	return {
-	    ticket => PVE::AccessControl::assemble_ticket($authuser),
-	    cap => $rpcenv->compute_api_permission($authuser),
-	}
-    }});
 
 __PACKAGE__->register_method({
     name => 'permissions',
diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index 4e49037..2817f48 100644
--- a/src/PVE/API2/Makefile
+++ b/src/PVE/API2/Makefile
@@ -6,6 +6,7 @@ API2_SOURCES= 		 	\
 	Role.pm		 	\
 	Group.pm	 	\
 	User.pm			\
+	TFA.pm			\
 	OpenId.pm
 
 .PHONY: install
diff --git a/src/PVE/API2/TFA.pm b/src/PVE/API2/TFA.pm
new file mode 100644
index 0000000..76daef9
--- /dev/null
+++ b/src/PVE/API2/TFA.pm
@@ -0,0 +1,239 @@
+package PVE::API2::TFA;
+
+use strict;
+use warnings;
+
+use PVE::AccessControl;
+use PVE::Cluster qw(cfs_read_file);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
+use PVE::RPCEnvironment;
+
+use PVE::API2::AccessControl; # for old login api get_u2f_instance method
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+### OLD API
+
+__PACKAGE__->register_method({
+    name => 'verify_tfa',
+    path => '',
+    method => 'POST',
+    permissions => { user => 'all' },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to access TFA information
+    description => 'Finish a u2f challenge.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    response => {
+		type => 'string',
+		description => 'The response to the current authentication challenge.',
+	    },
+	}
+    },
+    returns => {
+	type => 'object',
+	properties => {
+	    ticket => { type => 'string' },
+	    # cap
+	}
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+	my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
+
+	my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm, 0);
+	if (!defined($tfa_type)) {
+	    raise('no u2f data available');
+	}
+
+	eval {
+	    if ($tfa_type eq 'u2f') {
+		my $challenge = $rpcenv->get_u2f_challenge()
+		   or raise('no active challenge');
+
+		my $keyHandle = $tfa_data->{keyHandle};
+		my $publicKey = $tfa_data->{publicKey};
+		raise("incomplete u2f setup")
+		    if !defined($keyHandle) || !defined($publicKey);
+
+		my $u2f = PVE::API2::AccessControl::get_u2f_instance($rpcenv, $publicKey, $keyHandle);
+		$u2f->set_challenge($challenge);
+
+		my ($counter, $present) = $u2f->auth_verify($param->{response});
+		# Do we want to do anything with these?
+	    } else {
+		# sanity check before handing off to the verification code:
+		my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
+		my $config = $tfa_data->{config} or die "bad tfa entry\n";
+		PVE::AccessControl::verify_one_time_pw($tfa_type, $authuser, $keys, $config, $param->{response});
+	    }
+	};
+	if (my $err = $@) {
+	    my $clientip = $rpcenv->get_client_ip() || '';
+	    syslog('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
+	    die PVE::Exception->new("authentication failure\n", code => 401);
+	}
+
+	return {
+	    ticket => PVE::AccessControl::assemble_ticket($authuser),
+	    cap => $rpcenv->compute_api_permission($authuser),
+	}
+    }});
+
+### END OLD API
+
+my $TFA_TYPE_SCHEMA = {
+    type => 'string',
+    description => 'TFA Entry Type.',
+    enum => [qw(totp u2f webauthn recovery yubico)],
+};
+
+my %TFA_INFO_PROPERTIES = (
+    id => {
+	type => 'string',
+	description => 'The id used to reference this entry.',
+    },
+    description => {
+	type => 'string',
+	description => 'User chosen description for this entry.',
+    },
+    created => {
+	type => 'integer',
+	description => 'Creation time of this entry as unix epoch.',
+    },
+    enable => {
+	type => 'boolean',
+	description => 'Whether this TFA entry is currently enabled.',
+	optional => 1,
+	default => 1,
+    },
+);
+
+my $TYPED_TFA_ENTRY_SCHEMA = {
+    type => 'object',
+    description => 'TFA Entry.',
+    properties => {
+	type => $TFA_TYPE_SCHEMA,
+	%TFA_INFO_PROPERTIES,
+    },
+};
+
+my $TFA_ID_SCHEMA = {
+    type => 'string',
+    description => 'A TFA entry id.',
+};
+
+__PACKAGE__->register_method ({
+    name => 'list_user_tfa',
+    path => '{userid}',
+    method => 'GET',
+    permissions => {
+	check => [ 'or',
+	    ['userid-param', 'self'],
+	    ['userid-group', ['User.Modify', 'Sys.Audit']],
+	],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'List TFA configurations of users.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    userid => get_standard_option('userid', {
+		completion => \&PVE::AccessControl::complete_username,
+	    }),
+	}
+    },
+    returns => {
+	description => "A list of the user's TFA entries.",
+	type => 'array',
+	items => $TYPED_TFA_ENTRY_SCHEMA,
+    },
+    code => sub {
+	my ($param) = @_;
+	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+	return $tfa_cfg->api_list_user_tfa($param->{userid});
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'get_tfa_entry',
+    path => '{userid}/{id}',
+    method => 'GET',
+    permissions => {
+	check => [ 'or',
+	    ['userid-param', 'self'],
+	    ['userid-group', ['User.Modify', 'Sys.Audit']],
+	],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'A requested TFA entry if present.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    userid => get_standard_option('userid', {
+		completion => \&PVE::AccessControl::complete_username,
+	    }),
+	    id => $TFA_ID_SCHEMA,
+	}
+    },
+    returns => $TYPED_TFA_ENTRY_SCHEMA,
+    code => sub {
+	my ($param) = @_;
+	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+	return $tfa_cfg->api_get_tfa_entry($param->{userid}, $param->{id});
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'list_tfa',
+    path => '',
+    method => 'GET',
+    permissions => {
+	description => "Returns all or just the logged-in user, depending on privileges.",
+	user => 'all',
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'List TFA configurations of users.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {}
+    },
+    returns => {
+	description => "The list tuples of user and TFA entries.",
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => {
+		userid => {
+		    type => 'string',
+		    description => 'User this entry belongs to.',
+		},
+		entries => {
+		    type => 'array',
+		    items => $TYPED_TFA_ENTRY_SCHEMA,
+		},
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+
+	my $top_level_allowed = ($authuser eq 'root@pam');
+
+	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+	return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
+    }});
+
+1;
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index 29d22ac..c3d3d16 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -1720,75 +1720,6 @@ my $USER_CONTROLLED_TFA_TYPES = {
     oath => 1,
 };
 
-# Delete an entry by setting $data=undef in which case $type is ignored.
-# Otherwise both must be valid.
-sub user_set_tfa {
-    my ($userid, $realm, $type, $data, $cached_usercfg, $cached_domaincfg) = @_;
-
-    if (defined($data) && !defined($type)) {
-	# This is an internal usage error and should not happen
-	die "cannot set tfa data without a type\n";
-    }
-
-    my $user_cfg = $cached_usercfg || cfs_read_file('user.cfg');
-    my $user = $user_cfg->{users}->{$userid};
-
-    my $domain_cfg = $cached_domaincfg || cfs_read_file('domains.cfg');
-    my $realm_cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
-
-    my $realm_tfa = $realm_cfg->{tfa};
-    if (defined($realm_tfa)) {
-	$realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
-	# If the realm has a TFA setting, we're only allowed to use that.
-	if (defined($data)) {
-	    die "user '$userid' not found\n" if !defined($user);
-	    my $required_type = $realm_tfa->{type};
-	    if ($required_type ne $type) {
-		die "realm '$realm' only allows TFA of type '$required_type\n";
-	    }
-
-	    if (defined($data->{config})) {
-		# XXX: Is it enough if the type matches? Or should the configuration also match?
-	    }
-
-	    # realm-configured tfa always uses a simple key list, so use the user.cfg
-	    $user->{keys} = $data->{keys};
-	} else {
-	    # TFA is enforce by realm, only allow deletion if the whole user gets delete
-	    die "realm '$realm' does not allow removing the 2nd factor\n" if defined($user);
-	}
-    } else {
-	die "user '$userid' not found\n" if !defined($user) && defined($data);
-	# Without a realm-enforced TFA setting the user can add a u2f or totp entry by themselves.
-	# The 'yubico' type requires yubico server settings, which have to be configured on the
-	# realm, so this is not supported here:
-	die "domain '$realm' does not support TFA type '$type'\n"
-	    if defined($data) && !$USER_CONTROLLED_TFA_TYPES->{$type};
-    }
-
-    # Custom TFA entries are stored in priv/tfa.cfg as they can be more complet: u2f uses a
-    # public key and a key handle, TOTP requires the usual totp settings...
-
-    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-    my $tfa = ($tfa_cfg->{users}->{$userid} //= {});
-
-    if (defined($data)) {
-	$tfa->{type} = $type;
-	$tfa->{data} = $data;
-	cfs_write_file('priv/tfa.cfg', $tfa_cfg);
-
-	$user->{keys} = "x!$type";
-    } else {
-	delete $tfa_cfg->{users}->{$userid};
-	cfs_write_file('priv/tfa.cfg', $tfa_cfg);
-
-	delete $user->{keys} if defined($user);
-    }
-
-    cfs_write_file('user.cfg', $user_cfg) if defined($user);
-}
-
 sub user_get_tfa : prototype($$$) {
     my ($username, $realm, $new_format) = @_;
 
diff --git a/src/PVE/CLI/pveum.pm b/src/PVE/CLI/pveum.pm
index 5929707..95b5705 100755
--- a/src/PVE/CLI/pveum.pm
+++ b/src/PVE/CLI/pveum.pm
@@ -11,6 +11,7 @@ use PVE::API2::Role;
 use PVE::API2::ACL;
 use PVE::API2::AccessControl;
 use PVE::API2::Domains;
+use PVE::API2::TFA;
 use PVE::CLIFormatter;
 use PVE::CLIHandler;
 use PVE::JSONSchema qw(get_standard_option);
@@ -118,7 +119,7 @@ our $cmddef = {
 	list   => [ 'PVE::API2::User', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
 	permissions => [ 'PVE::API2::AccessControl', 'permissions', ['userid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
 	tfa => {
-	    delete => [ 'PVE::API2::AccessControl', 'change_tfa', ['userid'], { action => 'delete', key => undef, config => undef, response => undef, }, ],
+	    delete => [ 'PVE::API2::TFA', 'change_tfa', ['userid'], { action => 'delete', key => undef, config => undef, response => undef, }, ],
 	},
 	token => {
 	    add    => [ 'PVE::API2::User', 'generate_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 06/10] add pbs-style TFA API implementation
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (10 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 05/10] move TFA api path into its own module Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 07/10] support registering yubico otp keys Wolfgang Bumiller
                   ` (20 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

implements the same api paths as in pbs by forwarding the
api methods to the rust implementation after performing the
product-specific checks

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/TFA.pm      | 332 +++++++++++++++++++++++++++++++++------
 src/PVE/AccessControl.pm |  15 ++
 2 files changed, 301 insertions(+), 46 deletions(-)

diff --git a/src/PVE/API2/TFA.pm b/src/PVE/API2/TFA.pm
index 76daef9..1888699 100644
--- a/src/PVE/API2/TFA.pm
+++ b/src/PVE/API2/TFA.pm
@@ -4,7 +4,7 @@ use strict;
 use warnings;
 
 use PVE::AccessControl;
-use PVE::Cluster qw(cfs_read_file);
+use PVE::Cluster qw(cfs_read_file cfs_write_file);
 use PVE::JSONSchema qw(get_standard_option);
 use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
 use PVE::RPCEnvironment;
@@ -15,6 +15,110 @@ use PVE::RESTHandler;
 
 use base qw(PVE::RESTHandler);
 
+my $OPTIONAL_PASSWORD_SCHEMA = {
+    description => "The current password.",
+    type => 'string',
+    optional => 1, # Only required if not root@pam
+    minLength => 5,
+    maxLength => 64
+};
+
+my $TFA_TYPE_SCHEMA = {
+    type => 'string',
+    description => 'TFA Entry Type.',
+    enum => [qw(totp u2f webauthn recovery yubico)],
+};
+
+my %TFA_INFO_PROPERTIES = (
+    id => {
+	type => 'string',
+	description => 'The id used to reference this entry.',
+    },
+    description => {
+	type => 'string',
+	description => 'User chosen description for this entry.',
+    },
+    created => {
+	type => 'integer',
+	description => 'Creation time of this entry as unix epoch.',
+    },
+    enable => {
+	type => 'boolean',
+	description => 'Whether this TFA entry is currently enabled.',
+	optional => 1,
+	default => 1,
+    },
+);
+
+my $TYPED_TFA_ENTRY_SCHEMA = {
+    type => 'object',
+    description => 'TFA Entry.',
+    properties => {
+	type => $TFA_TYPE_SCHEMA,
+	%TFA_INFO_PROPERTIES,
+    },
+};
+
+my $TFA_ID_SCHEMA = {
+    type => 'string',
+    description => 'A TFA entry id.',
+};
+
+my $TFA_UPDATE_INFO_SCHEMA = {
+    type => 'object',
+    properties => {
+	id => {
+	    type => 'string',
+	    description => 'The id of a newly added TFA entry.',
+	},
+	challenge => {
+	    type => 'string',
+	    optional => 1,
+	    description =>
+		'When adding u2f entries, this contains a challenge the user must respond to in order'
+		.' to finish the registration.'
+	},
+	recovery => {
+	    type => 'array',
+	    optional => 1,
+	    description =>
+		'When adding recovery codes, this contains the list of codes to be displayed to'
+		.' the user',
+	    items => {
+		type => 'string',
+		description => 'A recovery entry.'
+	    },
+	},
+    },
+};
+
+# Only root may modify root, regular users need to specify their password.
+#
+# Returns the userid returned from `verify_username`.
+# Or ($userid, $realm) in list context.
+my sub root_permission_check : prototype($$$$) {
+    my ($rpcenv, $authuser, $userid, $password) = @_;
+
+    ($userid, my $ruid, my $realm) = PVE::AccessControl::verify_username($userid);
+    $rpcenv->check_user_exist($userid);
+
+    raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
+
+    # Regular users need to confirm their password to change TFA settings.
+    if ($authuser ne 'root@pam') {
+	raise_param_exc({ 'password' => 'password is required to modify TFA data' })
+	    if !defined($password);
+
+	my $domain_cfg = cfs_read_file('domains.cfg');
+	my $cfg = $domain_cfg->{ids}->{$realm};
+	die "auth domain '$realm' does not exist\n" if !$cfg;
+	my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
+	$plugin->authenticate_user($cfg, $realm, $ruid, $password);
+    }
+
+    return wantarray ? ($userid, $realm) : $userid;
+}
+
 ### OLD API
 
 __PACKAGE__->register_method({
@@ -89,47 +193,6 @@ __PACKAGE__->register_method({
 
 ### END OLD API
 
-my $TFA_TYPE_SCHEMA = {
-    type => 'string',
-    description => 'TFA Entry Type.',
-    enum => [qw(totp u2f webauthn recovery yubico)],
-};
-
-my %TFA_INFO_PROPERTIES = (
-    id => {
-	type => 'string',
-	description => 'The id used to reference this entry.',
-    },
-    description => {
-	type => 'string',
-	description => 'User chosen description for this entry.',
-    },
-    created => {
-	type => 'integer',
-	description => 'Creation time of this entry as unix epoch.',
-    },
-    enable => {
-	type => 'boolean',
-	description => 'Whether this TFA entry is currently enabled.',
-	optional => 1,
-	default => 1,
-    },
-);
-
-my $TYPED_TFA_ENTRY_SCHEMA = {
-    type => 'object',
-    description => 'TFA Entry.',
-    properties => {
-	type => $TFA_TYPE_SCHEMA,
-	%TFA_INFO_PROPERTIES,
-    },
-};
-
-my $TFA_ID_SCHEMA = {
-    type => 'string',
-    description => 'A TFA entry id.',
-};
-
 __PACKAGE__->register_method ({
     name => 'list_user_tfa',
     path => '{userid}',
@@ -174,7 +237,7 @@ __PACKAGE__->register_method ({
     },
     protected => 1, # else we can't access shadow files
     allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
-    description => 'A requested TFA entry if present.',
+    description => 'Fetch a requested TFA entry if present.',
     parameters => {
 	additionalProperties => 0,
 	properties => {
@@ -188,7 +251,51 @@ __PACKAGE__->register_method ({
     code => sub {
 	my ($param) = @_;
 	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-	return $tfa_cfg->api_get_tfa_entry($param->{userid}, $param->{id});
+	my $id = $param->{id};
+	my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
+	raise("No such tfa entry '$id'", 404) if !$entry;
+	return $entry;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'delete_tfa',
+    path => '{userid}/{id}',
+    method => 'DELETE',
+    permissions => {
+	check => [ 'or',
+	    ['userid-param', 'self'],
+	    ['userid-group', ['User.Modify']],
+	],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'Delete a TFA entry by ID.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    userid => get_standard_option('userid', {
+		completion => \&PVE::AccessControl::complete_username,
+	    }),
+	    id => $TFA_ID_SCHEMA,
+	    password => $OPTIONAL_PASSWORD_SCHEMA,
+	}
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::AccessControl::assert_new_tfa_config_available();
+	
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+	my $userid =
+	    root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+
+	return PVE::AccessControl::lock_tfa_config(sub {
+	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+	    $tfa_cfg->api_delete_tfa($userid, $param->{id});
+	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+	});
     }});
 
 __PACKAGE__->register_method ({
@@ -228,12 +335,145 @@ __PACKAGE__->register_method ({
 
 	my $rpcenv = PVE::RPCEnvironment::get();
 	my $authuser = $rpcenv->get_user();
-
-
 	my $top_level_allowed = ($authuser eq 'root@pam');
 
 	my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
 	return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
     }});
 
+__PACKAGE__->register_method ({
+    name => 'add_tfa_entry',
+    path => '{userid}',
+    method => 'POST',
+    permissions => {
+	check => [ 'or',
+	    ['userid-param', 'self'],
+	    ['userid-group', ['User.Modify']],
+	],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'Add a TFA entry for a user.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    userid => get_standard_option('userid', {
+		completion => \&PVE::AccessControl::complete_username,
+	    }),
+            type => $TFA_TYPE_SCHEMA,
+	    description => {
+		type => 'string',
+		description => 'A description to distinguish multiple entries from one another',
+		maxLength => 255,
+		optional => 1,
+	    },
+	    totp => {
+		type => 'string',
+		description => "A totp URI.",
+		optional => 1,
+	    },
+	    value => {
+		type => 'string',
+		description =>
+		    'The current value for the provided totp URI, or a Webauthn/U2F'
+		    .' challenge response',
+		optional => 1,
+	    },
+	    challenge => {
+		type => 'string',
+		description => 'When responding to a u2f challenge: the original challenge string',
+		optional => 1,
+	    },
+	    password => $OPTIONAL_PASSWORD_SCHEMA,
+	},
+    },
+    returns => $TFA_UPDATE_INFO_SCHEMA,
+    code => sub {
+	my ($param) = @_;
+
+	PVE::AccessControl::assert_new_tfa_config_available();
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+	my $userid =
+	    root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+
+	return PVE::AccessControl::lock_tfa_config(sub {
+	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+	    PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
+
+	    my $response = $tfa_cfg->api_add_tfa_entry(
+		$userid,
+		$param->{description},
+		$param->{totp},
+		$param->{value},
+		$param->{challenge},
+		$param->{type},
+	    );
+
+	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+
+	    return $response;
+	});
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_tfa_entry',
+    path => '{userid}/{id}',
+    method => 'PUT',
+    permissions => {
+	check => [ 'or',
+	    ['userid-param', 'self'],
+	    ['userid-group', ['User.Modify']],
+	],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'Add a TFA entry for a user.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    userid => get_standard_option('userid', {
+		completion => \&PVE::AccessControl::complete_username,
+	    }),
+	    id => $TFA_ID_SCHEMA,
+	    description => {
+		type => 'string',
+		description => 'A description to distinguish multiple entries from one another',
+		maxLength => 255,
+		optional => 1,
+	    },
+	    enable => {
+		type => 'boolean',
+		description => 'Whether the entry should be enabled for login.',
+		optional => 1,
+	    },
+	    password => $OPTIONAL_PASSWORD_SCHEMA,
+	},
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::AccessControl::assert_new_tfa_config_available();
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+	my $userid =
+	    root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+
+	PVE::AccessControl::lock_tfa_config(sub {
+	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+
+	    $tfa_cfg->api_update_tfa_entry(
+		$userid,
+		$param->{id},
+		$param->{description},
+		$param->{enable},
+	    );
+
+	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+	});
+    }});
+
 1;
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index c3d3d16..fd80368 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -77,6 +77,17 @@ sub lock_user_config {
     }
 }
 
+sub lock_tfa_config {
+    my ($code, $errmsg) = @_;
+
+    my $res = cfs_lock_file("priv/tfa.cfg", undef, $code);
+    if (my $err = $@) {
+	$errmsg ? die "$errmsg: $err" : die $err;
+    }
+
+    return $res;
+}
+
 my $cache_read_key = sub {
     my ($type) = @_;
 
@@ -1720,6 +1731,10 @@ my $USER_CONTROLLED_TFA_TYPES = {
     oath => 1,
 };
 
+sub assert_new_tfa_config_available() {
+    # FIXME: Assert cluster-wide new-tfa-config support!
+}
+
 sub user_get_tfa : prototype($$$) {
     my ($username, $realm, $new_format) = @_;
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 07/10] support registering yubico otp keys
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (11 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 06/10] add pbs-style TFA API implementation Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 08/10] update tfa cleanup when deleting users Wolfgang Bumiller
                   ` (19 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

In PBS we don't support this, so the current TFA API in rust
does not support this either (although the config does know
about its *existence*).

For now, yubico authentication will be done in perl. Adding
it to rust the rust TFA crate would not make much sense
anyway as we'd likely not want to use the same http client
crate in pve and pbs anyway (since pve is all blocking code
and pbs is async...)

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/TFA.pm      | 34 +++++++++++++++++++++++++++++++---
 src/PVE/AccessControl.pm | 15 ++++++++++-----
 2 files changed, 41 insertions(+), 8 deletions(-)

diff --git a/src/PVE/API2/TFA.pm b/src/PVE/API2/TFA.pm
index 1888699..2fbc7a8 100644
--- a/src/PVE/API2/TFA.pm
+++ b/src/PVE/API2/TFA.pm
@@ -395,9 +395,15 @@ __PACKAGE__->register_method ({
 
 	my $rpcenv = PVE::RPCEnvironment::get();
 	my $authuser = $rpcenv->get_user();
-	my $userid =
+	my ($userid, $realm) =
 	    root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
 
+	my $type = delete $param->{type};
+	my $value = delete $param->{value};
+	if ($type eq 'yubico') {
+	    $value = validate_yubico_otp($userid, $realm, $value);
+	}
+
 	return PVE::AccessControl::lock_tfa_config(sub {
 	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
 	    PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
@@ -406,9 +412,9 @@ __PACKAGE__->register_method ({
 		$userid,
 		$param->{description},
 		$param->{totp},
-		$param->{value},
+		$value,
 		$param->{challenge},
-		$param->{type},
+		$type,
 	    );
 
 	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
@@ -417,6 +423,28 @@ __PACKAGE__->register_method ({
 	});
     }});
 
+sub validate_yubico_otp : prototype($$) {
+    my ($userid, $realm, $value) = @_;
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+    my $realm_cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+    my $realm_tfa = $realm_cfg->{tfa};
+    die "no yubico otp configuration available for realm $realm\n"
+	if !$realm_tfa;
+
+    $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
+    die "realm is not setup for Yubico OTP\n"
+	if !$realm_tfa || $realm_tfa->{type} ne 'yubico';
+
+    my $public_key = substr($value, 0, 12);
+
+    PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa);
+
+    return $public_key;
+}
+
 __PACKAGE__->register_method ({
     name => 'update_tfa_entry',
     path => '{userid}/{id}',
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index fd80368..cd46507 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -806,16 +806,21 @@ sub authenticate_yubico_new : prototype($$$) {
     my $keys = $tfa_cfg->get_yubico_keys($username);
     die "no keys configured\n" if !defined($keys) || !length($keys);
 
-    # Defer to after unlocking the TFA config:
-
-    # fixme: proxy support?
-    my $proxy;
-    PVE::OTP::yubico_verify_otp($otp, $keys, $realm->{url}, $realm->{id}, $realm->{key}, $proxy);
+    authenticate_yubico_do($otp, $keys, $realm);
 
     # return `undef` to clear the tfa challenge.
     return undef;
 }
 
+sub authenticate_yubico_do : prototype($$$) {
+    my ($value, $keys, $realm) = @_;
+
+    # fixme: proxy support?
+    my $proxy = undef;
+
+    PVE::OTP::yubico_verify_otp($value, $keys, $realm->{url}, $realm->{id}, $realm->{key}, $proxy);
+}
+
 sub configure_u2f_and_wa : prototype($) {
     my ($tfa_cfg) = @_;
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 08/10] update tfa cleanup when deleting users
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (12 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 07/10] support registering yubico otp keys Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 09/10] pveum: update tfa delete command Wolfgang Bumiller
                   ` (18 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/User.pm     |  2 +-
 src/PVE/AccessControl.pm | 10 ++++++++++
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/PVE/API2/User.pm b/src/PVE/API2/User.pm
index 3d4d4e0..244264e 100644
--- a/src/PVE/API2/User.pm
+++ b/src/PVE/API2/User.pm
@@ -453,7 +453,7 @@ __PACKAGE__->register_method ({
 
 	    my $partial_deletion = '';
 	    eval {
-		PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef, $usercfg, $domain_cfg);
+		PVE::AccessControl::user_remove_tfa($userid);
 		$partial_deletion = ' - but deleted related TFA';
 
 		PVE::AccessControl::delete_user_group($userid, $usercfg);
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index cd46507..0b00847 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -1740,6 +1740,16 @@ sub assert_new_tfa_config_available() {
     # FIXME: Assert cluster-wide new-tfa-config support!
 }
 
+sub user_remove_tfa : prototype($) {
+    my ($userid) = @_;
+
+    assert_new_tfa_config_available();
+
+    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+    $tfa_cfg->remove_user($userid);
+    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+}
+
 sub user_get_tfa : prototype($$$) {
     my ($username, $realm, $new_format) = @_;
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 09/10] pveum: update tfa delete command
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (13 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 08/10] update tfa cleanup when deleting users Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 10/10] set/remove 'x' for tfa keys in user.cfg in new api Wolfgang Bumiller
                   ` (17 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/CLI/pveum.pm | 40 +++++++++++++++++++++++++++++++++++++++-
 1 file changed, 39 insertions(+), 1 deletion(-)

diff --git a/src/PVE/CLI/pveum.pm b/src/PVE/CLI/pveum.pm
index 95b5705..44399b6 100755
--- a/src/PVE/CLI/pveum.pm
+++ b/src/PVE/CLI/pveum.pm
@@ -12,6 +12,7 @@ use PVE::API2::ACL;
 use PVE::API2::AccessControl;
 use PVE::API2::Domains;
 use PVE::API2::TFA;
+use PVE::Cluster qw(cfs_read_file cfs_write_file);
 use PVE::CLIFormatter;
 use PVE::CLIHandler;
 use PVE::JSONSchema qw(get_standard_option);
@@ -111,6 +112,43 @@ __PACKAGE__->register_method({
 	return PVE::API2::AccessControl->permissions($param);
     }});
 
+__PACKAGE__->register_method({
+    name => 'delete_tfa',
+    path => 'delete_tfa',
+    method => 'PUT',
+    description => 'Delete TFA entries from a user.',
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    userid => get_standard_option('userid'),
+	    id => {
+		description => "The TFA ID, if none provided, all TFA entries will be deleted.",
+		type => 'string',
+		optional => 1,
+	    },
+	},
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $userid = extract_param($param, "userid");
+	my $tfa_id = extract_param($param, "id");
+
+	PVE::AccessControl::assert_new_tfa_config_available();
+
+	PVE::AccessControl::lock_tfa_config(sub {
+	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+	    if (defined($tfa_id)) {
+		$tfa_cfg->api_delete_tfa($userid, $tfa_id);
+	    } else {
+		$tfa_cfg->remove_user($userid);
+	    }
+	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+	});
+	return;
+    }});
+
 our $cmddef = {
     user => {
 	add    => [ 'PVE::API2::User', 'create_user', ['userid'] ],
@@ -119,7 +157,7 @@ our $cmddef = {
 	list   => [ 'PVE::API2::User', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
 	permissions => [ 'PVE::API2::AccessControl', 'permissions', ['userid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
 	tfa => {
-	    delete => [ 'PVE::API2::TFA', 'change_tfa', ['userid'], { action => 'delete', key => undef, config => undef, response => undef, }, ],
+	    delete => [ __PACKAGE__, 'delete_tfa', ['userid'] ],
 	},
 	token => {
 	    add    => [ 'PVE::API2::User', 'generate_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH access-control 10/10] set/remove 'x' for tfa keys in user.cfg in new api
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (14 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 09/10] pveum: update tfa delete command Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH cluster] add webauthn configuration to datacenter.cfg Wolfgang Bumiller
                   ` (16 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/API2/TFA.pm | 26 ++++++++++++++++++++++++--
 1 file changed, 24 insertions(+), 2 deletions(-)

diff --git a/src/PVE/API2/TFA.pm b/src/PVE/API2/TFA.pm
index 2fbc7a8..53f57fc 100644
--- a/src/PVE/API2/TFA.pm
+++ b/src/PVE/API2/TFA.pm
@@ -119,6 +119,22 @@ my sub root_permission_check : prototype($$$$) {
     return wantarray ? ($userid, $realm) : $userid;
 }
 
+my sub set_user_tfa_enabled : prototype($$) {
+    my ($userid, $enabled) = @_;
+
+    PVE::AccessControl::lock_user_config(sub {
+	my $user_cfg = cfs_read_file('user.cfg');
+	my $user = $user_cfg->{users}->{$userid};
+	my $keys = $user->{keys};
+	if ($keys && $keys !~ /^x(?:!.*)?$/) {
+	    die "user contains tfa keys directly in user.cfg,"
+		." please remove them and add them via the TFA panel instead\n";
+	}
+	$user->{keys} = $enabled ? 'x' : undef;
+	cfs_write_file("user.cfg", $user_cfg);
+    }, "enabling TFA for the user failed");
+}
+
 ### OLD API
 
 __PACKAGE__->register_method({
@@ -291,11 +307,15 @@ __PACKAGE__->register_method ({
 	my $userid =
 	    root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
 
-	return PVE::AccessControl::lock_tfa_config(sub {
+	my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
 	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-	    $tfa_cfg->api_delete_tfa($userid, $param->{id});
+	    my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
 	    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+	    return $has_entries_left;
 	});
+	if (!$has_entries_left) {
+	    set_user_tfa_enabled($userid, 0);
+	}
     }});
 
 __PACKAGE__->register_method ({
@@ -404,6 +424,8 @@ __PACKAGE__->register_method ({
 	    $value = validate_yubico_otp($userid, $realm, $value);
 	}
 
+	set_user_tfa_enabled($userid, 1);
+
 	return PVE::AccessControl::lock_tfa_config(sub {
 	    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
 	    PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH cluster] add webauthn configuration to datacenter.cfg
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (15 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH access-control 10/10] set/remove 'x' for tfa keys in user.cfg in new api Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10 10:12   ` [pve-devel] applied: " Thomas Lamprecht
  2021-11-09 11:27 ` [pve-devel] [PATCH common] Ticket: uri-escape colons Wolfgang Bumiller
                   ` (15 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 data/PVE/DataCenterConfig.pm | 43 ++++++++++++++++++++++++++++++++++++
 1 file changed, 43 insertions(+)

diff --git a/data/PVE/DataCenterConfig.pm b/data/PVE/DataCenterConfig.pm
index fa8ba4a..2e802d3 100644
--- a/data/PVE/DataCenterConfig.pm
+++ b/data/PVE/DataCenterConfig.pm
@@ -66,6 +66,34 @@ my $u2f_format = {
     },
 };
 
+my $webauthn_format = {
+    rp => {
+	type => 'string',
+	description =>
+	    'Relying party name. Any text identifier.'
+	    .' Changing this *may* break existing credentials.',
+	format_description => 'RELYING_PARTY',
+	optional => 1,
+    },
+    origin => {
+	type => 'string',
+	description =>
+	    'Site origin. Must be a `https://` URL (or `http://localhost`).'
+	    .' Should contain the address users type in their browsers to access'
+	    .' the web interface.'
+	    .' Changing this *may* break existing credentials.',
+	format_description => 'URL',
+	optional => 1,
+    },
+    id => {
+	type => 'string',
+	description =>
+	    'Relying part ID. Must be the domain name without protocol, port or location.'
+	    .' Changing this *will* break existing credentials.',
+	format_description => 'DOMAINNAME',
+	optional => 1,
+    },
+};
 
 PVE::JSONSchema::register_format('mac-prefix', \&pve_verify_mac_prefix);
 sub pve_verify_mac_prefix {
@@ -181,6 +209,12 @@ my $datacenter_schema = {
 	    format => $u2f_format,
 	    description => 'u2f',
 	},
+	webauthn => {
+	    optional => 1,
+	    type => 'string',
+	    format => $webauthn_format,
+	    description => 'webauthn configuration',
+	},
 	description => {
 	    type => 'string',
 	    description => "Datacenter description. Shown in the web-interface datacenter notes panel."
@@ -224,6 +258,10 @@ sub parse_datacenter_config {
 	$res->{u2f} = PVE::JSONSchema::parse_property_string($u2f_format, $u2f);
     }
 
+    if (my $webauthn = $res->{webauthn}) {
+	$res->{webauthn} = PVE::JSONSchema::parse_property_string($webauthn_format, $webauthn);
+    }
+
     # for backwards compatibility only, new migration property has precedence
     if (defined($res->{migration_unsecure})) {
 	if (defined($res->{migration}->{type})) {
@@ -271,6 +309,11 @@ sub write_datacenter_config {
 	$cfg->{u2f} = PVE::JSONSchema::print_property_string($u2f, $u2f_format);
     }
 
+    if (ref($cfg->{webauthn})) {
+	my $webauthn = $cfg->{webauthn};
+	$cfg->{webauthn} = PVE::JSONSchema::print_property_string($webauthn, $webauthn_format);
+    }
+
     my $comment = '';
     # add description as comment to top of file
     my $description = $cfg->{description} || '';
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH common] Ticket: uri-escape colons
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (16 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH cluster] add webauthn configuration to datacenter.cfg Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 12:26   ` [pve-devel] applied: " Thomas Lamprecht
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 1/7] www: use render_u2f_error from wtk Wolfgang Bumiller
                   ` (14 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/PVE/Ticket.pm | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/src/PVE/Ticket.pm b/src/PVE/Ticket.pm
index d522401..ce8d5c8 100644
--- a/src/PVE/Ticket.pm
+++ b/src/PVE/Ticket.pm
@@ -8,6 +8,7 @@ use Crypt::OpenSSL::RSA;
 use MIME::Base64;
 use Digest::SHA;
 use Time::HiRes qw(gettimeofday);
+use URI::Escape;
 
 use PVE::Exception qw(raise);
 
@@ -60,7 +61,10 @@ sub assemble_rsa_ticket {
 
     my $plain = "$prefix:";
 
-    $plain .= "$data:" if defined($data);
+    if (defined($data)) {
+	$data = uri_escape($data, ':');
+	$plain .= "$data:";
+    }
 
     $plain .= $timestamp;
 
@@ -88,6 +92,10 @@ sub verify_rsa_ticket {
 
 		my $age = time() - $ttime;
 
+		if (defined($data)) {
+		    $data = uri_unescape($data);
+		}
+
 		if (($age > $min_age) && ($age < $max_age)) {
 		    if (defined($data)) {
 			return wantarray ? ($data, $age) : $data;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 1/7] www: use render_u2f_error from wtk
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (17 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH common] Ticket: uri-escape colons Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 2/7] www: use UserSelector " Wolfgang Bumiller
                   ` (13 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 www/manager6/Utils.js              | 11 -----------
 www/manager6/dc/TFAEdit.js         |  2 +-
 www/manager6/window/LoginWindow.js |  2 +-
 3 files changed, 2 insertions(+), 13 deletions(-)

diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 274d4db2..c907eecf 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -1302,17 +1302,6 @@ Ext.define('PVE.Utils', {
 	return Ext.htmlEncode(first + " " + last);
     },
 
-    render_u2f_error: function(error) {
-	var ErrorNames = {
-	    '1': gettext('Other Error'),
-	    '2': gettext('Bad Request'),
-	    '3': gettext('Configuration Unsupported'),
-	    '4': gettext('Device Ineligible'),
-	    '5': gettext('Timeout'),
-	};
-	return "U2F Error: " + ErrorNames[error] || Proxmox.Utils.unknownText;
-    },
-
     windowHostname: function() {
 	return window.location.hostname.replace(Proxmox.Utils.IP6_bracket_match,
             function(m, addr, offset, original) { return addr; });
diff --git a/www/manager6/dc/TFAEdit.js b/www/manager6/dc/TFAEdit.js
index 54e8d87f..57a73b39 100644
--- a/www/manager6/dc/TFAEdit.js
+++ b/www/manager6/dc/TFAEdit.js
@@ -44,7 +44,7 @@ Ext.define('PVE.window.TFAEdit', {
     showError: function(error) {
 	Ext.Msg.alert(
 	    gettext('Error'),
-	    PVE.Utils.render_u2f_error(error),
+	    Proxmox.Utils.render_u2f_error(error),
 	);
     },
 
diff --git a/www/manager6/window/LoginWindow.js b/www/manager6/window/LoginWindow.js
index 2f23cbb5..ecd198a5 100644
--- a/www/manager6/window/LoginWindow.js
+++ b/www/manager6/window/LoginWindow.js
@@ -151,7 +151,7 @@ Ext.define('PVE.window.LoginWindow', {
 		msg.close();
 		if (res.errorCode) {
 		    Proxmox.Utils.authClear();
-		    Ext.Msg.alert(gettext('Error'), PVE.Utils.render_u2f_error(res.errorCode));
+		    Ext.Msg.alert(gettext('Error'), Proxmox.Utils.render_u2f_error(res.errorCode));
 		    return;
 		}
 		delete res.errorCode;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 2/7] www: use UserSelector from wtk
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (18 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 1/7] www: use render_u2f_error from wtk Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 3/7] use u2f-api.js and qrcode.min.js " Wolfgang Bumiller
                   ` (12 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 www/manager6/dc/ACLView.js        |  2 +-
 www/manager6/dc/TokenEdit.js      |  2 +-
 www/manager6/dc/UserView.js       |  2 +-
 www/manager6/form/UserSelector.js | 83 ++++---------------------------
 4 files changed, 14 insertions(+), 75 deletions(-)

diff --git a/www/manager6/dc/ACLView.js b/www/manager6/dc/ACLView.js
index 65abd8cd..a5776ac6 100644
--- a/www/manager6/dc/ACLView.js
+++ b/www/manager6/dc/ACLView.js
@@ -32,7 +32,7 @@ Ext.define('PVE.dc.ACLAdd', {
 	} else if (me.aclType === 'user') {
 	    me.subject = gettext("User Permission");
 	    items.push({
-		xtype: 'pveUserSelector',
+		xtype: 'pmxUserSelector',
 		name: 'users',
 		fieldLabel: gettext('User'),
 	    });
diff --git a/www/manager6/dc/TokenEdit.js b/www/manager6/dc/TokenEdit.js
index 7039249c..3b25c739 100644
--- a/www/manager6/dc/TokenEdit.js
+++ b/www/manager6/dc/TokenEdit.js
@@ -37,7 +37,7 @@ Ext.define('PVE.dc.TokenEdit', {
 		},
 		submitValue: true,
 		editConfig: {
-		    xtype: 'pveUserSelector',
+		    xtype: 'pmxUserSelector',
 		    allowBlank: false,
 		},
 		name: 'userid',
diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js
index ef394bdb..9c84bf7d 100644
--- a/www/manager6/dc/UserView.js
+++ b/www/manager6/dc/UserView.js
@@ -15,7 +15,7 @@ Ext.define('PVE.dc.UserView', {
 
 	var store = new Ext.data.Store({
             id: "users",
-	    model: 'pve-users',
+	    model: 'pmx-users',
 	    sorters: {
 		property: 'userid',
 		order: 'DESC',
diff --git a/www/manager6/form/UserSelector.js b/www/manager6/form/UserSelector.js
index 2d4c8c22..8fb31d7e 100644
--- a/www/manager6/form/UserSelector.js
+++ b/www/manager6/form/UserSelector.js
@@ -1,74 +1,13 @@
-Ext.define('PVE.form.UserSelector', {
-    extend: 'Proxmox.form.ComboGrid',
-    alias: ['widget.pveUserSelector'],
-
-    allowBlank: false,
-    autoSelect: false,
-    valueField: 'userid',
-    displayField: 'userid',
-
-    editable: true,
-    anyMatch: true,
-    forceSelection: true,
-
-    initComponent: function() {
-	var me = this;
-
-	var store = new Ext.data.Store({
-	    model: 'pve-users',
-	    sorters: [{
-		property: 'userid',
-	    }],
-	});
-
-	Ext.apply(me, {
-	    store: store,
-            listConfig: {
-		columns: [
-		    {
-			header: gettext('User'),
-			sortable: true,
-			dataIndex: 'userid',
-			renderer: Ext.String.htmlEncode,
-			flex: 1,
-		    },
-		    {
-			header: gettext('Name'),
-			sortable: true,
-			renderer: PVE.Utils.render_full_name,
-			dataIndex: 'firstname',
-			flex: 1,
-		    },
-		    {
-			header: gettext('Comment'),
-			sortable: false,
-			dataIndex: 'comment',
-			renderer: Ext.String.htmlEncode,
-			flex: 1,
-		    },
-		],
-	    },
-	});
-
-        me.callParent();
-
-	store.load({ params: { enabled: 1 } });
+Ext.define('pmx-users', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'userid', 'firstname', 'lastname', 'email', 'comment',
+	{ type: 'boolean', name: 'enable' },
+	{ type: 'date', dateFormat: 'timestamp', name: 'expire' },
+    ],
+    proxy: {
+	type: 'proxmox',
+	url: "/api2/json/access/users",
     },
-
-}, function() {
-    Ext.define('pve-users', {
-	extend: 'Ext.data.Model',
-	fields: [
-	    'userid', 'firstname', 'lastname', 'email', 'comment',
-	    { type: 'boolean', name: 'enable' },
-	    { type: 'date', dateFormat: 'timestamp', name: 'expire' },
-	],
-	proxy: {
-            type: 'proxmox',
-	    url: "/api2/json/access/users",
-	},
-	idProperty: 'userid',
-    });
+    idProperty: 'userid',
 });
-
-
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 3/7] use u2f-api.js and qrcode.min.js from wtk
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (19 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 2/7] www: use UserSelector " Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 4/7] www: switch to new tfa login format Wolfgang Bumiller
                   ` (11 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 PVE/Service/pveproxy.pm |   6 +
 www/Makefile            |   2 -
 www/index.html.tpl      |   4 +-
 www/qrcode.min.js       |   1 -
 www/u2f-api.js          | 748 ----------------------------------------
 5 files changed, 8 insertions(+), 753 deletions(-)
 delete mode 100644 www/qrcode.min.js
 delete mode 100644 www/u2f-api.js

diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index d10c4fe9..cff73332 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -120,6 +120,12 @@ sub init {
 	    '/proxmoxlib.js' => {
 		file => "$basedirs->{widgettoolkit}/proxmoxlib.js",
 	    },
+	    '/qrcode.min.js' => {
+		file => "$basedirs->{widgettoolkit}/qrcode.min.js",
+	    },
+	    '/u2f-api.js' => {
+		file => "$basedirs->{widgettoolkit}/u2f-api.js",
+	    },
 	},
 	dirs => $dirs,
     };
diff --git a/www/Makefile b/www/Makefile
index 639ad4b4..b9857fc1 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -8,8 +8,6 @@ install:
 	set -e && for i in ${SUBDIRS}; do ${MAKE} -C $$i $@; done
 	install -m 0644 index.html.tpl ${WWWBASEDIR}
 	install -d ${WWWJSDIR}
-	install -m 0644 u2f-api.js ${WWWJSDIR}
-	install -m 0644 qrcode.min.js ${WWWJSDIR}
 
 .PHONY: check
 check:
diff --git a/www/index.html.tpl b/www/index.html.tpl
index 7f07ea18..62a6eff1 100644
--- a/www/index.html.tpl
+++ b/www/index.html.tpl
@@ -24,8 +24,8 @@
     <script type="text/javascript" src="/pve2/ext6/ext-all.js?ver=7.0.0"></script>
     <script type="text/javascript" src="/pve2/ext6/charts.js?ver=7.0.0"></script>
     [% END %]
-    <script type="text/javascript" src="/pve2/js/u2f-api.js"></script>
-    <script type="text/javascript" src="/pve2/js/qrcode.min.js"></script>
+    <script type="text/javascript" src="/u2f-api.js?ver=[% wtversion %]"></script>
+    <script type="text/javascript" src="/qrcode.min.js?ver=[% wtversion %]"></script>
     <script type="text/javascript">
     Proxmox = {
 	Setup: { auth_cookie_name: 'PVEAuthCookie' },
diff --git a/www/qrcode.min.js b/www/qrcode.min.js
deleted file mode 100644
index 993e88f3..00000000
--- a/www/qrcode.min.js
+++ /dev/null
@@ -1 +0,0 @@
-var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
\ No newline at end of file
diff --git a/www/u2f-api.js b/www/u2f-api.js
deleted file mode 100644
index 9244d14e..00000000
--- a/www/u2f-api.js
+++ /dev/null
@@ -1,748 +0,0 @@
-//Copyright 2014-2015 Google Inc. All rights reserved.
-
-//Use of this source code is governed by a BSD-style
-//license that can be found in the LICENSE file or at
-//https://developers.google.com/open-source/licenses/bsd
-
-/**
- * @fileoverview The U2F api.
- */
-'use strict';
-
-
-/**
- * Namespace for the U2F api.
- * @type {Object}
- */
-var u2f = u2f || {};
-
-/**
- * FIDO U2F Javascript API Version
- * @number
- */
-var js_api_version;
-
-/**
- * The U2F extension id
- * @const {string}
- */
-// The Chrome packaged app extension ID.
-// Uncomment this if you want to deploy a server instance that uses
-// the package Chrome app and does not require installing the U2F Chrome extension.
- u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd';
-// The U2F Chrome extension ID.
-// Uncomment this if you want to deploy a server instance that uses
-// the U2F Chrome extension to authenticate.
-// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne';
-
-
-/**
- * Message types for messsages to/from the extension
- * @const
- * @enum {string}
- */
-u2f.MessageTypes = {
-    'U2F_REGISTER_REQUEST': 'u2f_register_request',
-    'U2F_REGISTER_RESPONSE': 'u2f_register_response',
-    'U2F_SIGN_REQUEST': 'u2f_sign_request',
-    'U2F_SIGN_RESPONSE': 'u2f_sign_response',
-    'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request',
-    'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response'
-};
-
-
-/**
- * Response status codes
- * @const
- * @enum {number}
- */
-u2f.ErrorCodes = {
-    'OK': 0,
-    'OTHER_ERROR': 1,
-    'BAD_REQUEST': 2,
-    'CONFIGURATION_UNSUPPORTED': 3,
-    'DEVICE_INELIGIBLE': 4,
-    'TIMEOUT': 5
-};
-
-
-/**
- * A message for registration requests
- * @typedef {{
- *   type: u2f.MessageTypes,
- *   appId: ?string,
- *   timeoutSeconds: ?number,
- *   requestId: ?number
- * }}
- */
-u2f.U2fRequest;
-
-
-/**
- * A message for registration responses
- * @typedef {{
- *   type: u2f.MessageTypes,
- *   responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse),
- *   requestId: ?number
- * }}
- */
-u2f.U2fResponse;
-
-
-/**
- * An error object for responses
- * @typedef {{
- *   errorCode: u2f.ErrorCodes,
- *   errorMessage: ?string
- * }}
- */
-u2f.Error;
-
-/**
- * Data object for a single sign request.
- * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}}
- */
-u2f.Transport;
-
-
-/**
- * Data object for a single sign request.
- * @typedef {Array<u2f.Transport>}
- */
-u2f.Transports;
-
-/**
- * Data object for a single sign request.
- * @typedef {{
- *   version: string,
- *   challenge: string,
- *   keyHandle: string,
- *   appId: string
- * }}
- */
-u2f.SignRequest;
-
-
-/**
- * Data object for a sign response.
- * @typedef {{
- *   keyHandle: string,
- *   signatureData: string,
- *   clientData: string
- * }}
- */
-u2f.SignResponse;
-
-
-/**
- * Data object for a registration request.
- * @typedef {{
- *   version: string,
- *   challenge: string
- * }}
- */
-u2f.RegisterRequest;
-
-
-/**
- * Data object for a registration response.
- * @typedef {{
- *   version: string,
- *   keyHandle: string,
- *   transports: Transports,
- *   appId: string
- * }}
- */
-u2f.RegisterResponse;
-
-
-/**
- * Data object for a registered key.
- * @typedef {{
- *   version: string,
- *   keyHandle: string,
- *   transports: ?Transports,
- *   appId: ?string
- * }}
- */
-u2f.RegisteredKey;
-
-
-/**
- * Data object for a get API register response.
- * @typedef {{
- *   js_api_version: number
- * }}
- */
-u2f.GetJsApiVersionResponse;
-
-
-//Low level MessagePort API support
-
-/**
- * Sets up a MessagePort to the U2F extension using the
- * available mechanisms.
- * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback
- */
-u2f.getMessagePort = function(callback) {
-  if (typeof chrome != 'undefined' && chrome.runtime) {
-    // The actual message here does not matter, but we need to get a reply
-    // for the callback to run. Thus, send an empty signature request
-    // in order to get a failure response.
-    var msg = {
-        type: u2f.MessageTypes.U2F_SIGN_REQUEST,
-        signRequests: []
-    };
-    chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() {
-      if (!chrome.runtime.lastError) {
-        // We are on a whitelisted origin and can talk directly
-        // with the extension.
-        u2f.getChromeRuntimePort_(callback);
-      } else {
-        // chrome.runtime was available, but we couldn't message
-        // the extension directly, use iframe
-        u2f.getIframePort_(callback);
-      }
-    });
-  } else if (u2f.isAndroidChrome_()) {
-    u2f.getAuthenticatorPort_(callback);
-  } else if (u2f.isIosChrome_()) {
-    u2f.getIosPort_(callback);
-  } else {
-    // chrome.runtime was not available at all, which is normal
-    // when this origin doesn't have access to any extensions.
-    u2f.getIframePort_(callback);
-  }
-};
-
-/**
- * Detect chrome running on android based on the browser's useragent.
- * @private
- */
-u2f.isAndroidChrome_ = function() {
-  var userAgent = navigator.userAgent;
-  return userAgent.indexOf('Chrome') != -1 &&
-  userAgent.indexOf('Android') != -1;
-};
-
-/**
- * Detect chrome running on iOS based on the browser's platform.
- * @private
- */
-u2f.isIosChrome_ = function() {
-  return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1;
-};
-
-/**
- * Connects directly to the extension via chrome.runtime.connect.
- * @param {function(u2f.WrappedChromeRuntimePort_)} callback
- * @private
- */
-u2f.getChromeRuntimePort_ = function(callback) {
-  var port = chrome.runtime.connect(u2f.EXTENSION_ID,
-      {'includeTlsChannelId': true});
-  setTimeout(function() {
-    callback(new u2f.WrappedChromeRuntimePort_(port));
-  }, 0);
-};
-
-/**
- * Return a 'port' abstraction to the Authenticator app.
- * @param {function(u2f.WrappedAuthenticatorPort_)} callback
- * @private
- */
-u2f.getAuthenticatorPort_ = function(callback) {
-  setTimeout(function() {
-    callback(new u2f.WrappedAuthenticatorPort_());
-  }, 0);
-};
-
-/**
- * Return a 'port' abstraction to the iOS client app.
- * @param {function(u2f.WrappedIosPort_)} callback
- * @private
- */
-u2f.getIosPort_ = function(callback) {
-  setTimeout(function() {
-    callback(new u2f.WrappedIosPort_());
-  }, 0);
-};
-
-/**
- * A wrapper for chrome.runtime.Port that is compatible with MessagePort.
- * @param {Port} port
- * @constructor
- * @private
- */
-u2f.WrappedChromeRuntimePort_ = function(port) {
-  this.port_ = port;
-};
-
-/**
- * Format and return a sign request compliant with the JS API version supported by the extension.
- * @param {Array<u2f.SignRequest>} signRequests
- * @param {number} timeoutSeconds
- * @param {number} reqId
- * @return {Object}
- */
-u2f.formatSignRequest_ =
-  function(appId, challenge, registeredKeys, timeoutSeconds, reqId) {
-  if (js_api_version === undefined || js_api_version < 1.1) {
-    // Adapt request to the 1.0 JS API
-    var signRequests = [];
-    for (var i = 0; i < registeredKeys.length; i++) {
-      signRequests[i] = {
-          version: registeredKeys[i].version,
-          challenge: challenge,
-          keyHandle: registeredKeys[i].keyHandle,
-          appId: appId
-      };
-    }
-    return {
-      type: u2f.MessageTypes.U2F_SIGN_REQUEST,
-      signRequests: signRequests,
-      timeoutSeconds: timeoutSeconds,
-      requestId: reqId
-    };
-  }
-  // JS 1.1 API
-  return {
-    type: u2f.MessageTypes.U2F_SIGN_REQUEST,
-    appId: appId,
-    challenge: challenge,
-    registeredKeys: registeredKeys,
-    timeoutSeconds: timeoutSeconds,
-    requestId: reqId
-  };
-};
-
-/**
- * Format and return a register request compliant with the JS API version supported by the extension..
- * @param {Array<u2f.SignRequest>} signRequests
- * @param {Array<u2f.RegisterRequest>} signRequests
- * @param {number} timeoutSeconds
- * @param {number} reqId
- * @return {Object}
- */
-u2f.formatRegisterRequest_ =
-  function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) {
-  if (js_api_version === undefined || js_api_version < 1.1) {
-    // Adapt request to the 1.0 JS API
-    for (var i = 0; i < registerRequests.length; i++) {
-      registerRequests[i].appId = appId;
-    }
-    var signRequests = [];
-    for (var i = 0; i < registeredKeys.length; i++) {
-      signRequests[i] = {
-          version: registeredKeys[i].version,
-          challenge: registerRequests[0],
-          keyHandle: registeredKeys[i].keyHandle,
-          appId: appId
-      };
-    }
-    return {
-      type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
-      signRequests: signRequests,
-      registerRequests: registerRequests,
-      timeoutSeconds: timeoutSeconds,
-      requestId: reqId
-    };
-  }
-  // JS 1.1 API
-  return {
-    type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
-    appId: appId,
-    registerRequests: registerRequests,
-    registeredKeys: registeredKeys,
-    timeoutSeconds: timeoutSeconds,
-    requestId: reqId
-  };
-};
-
-
-/**
- * Posts a message on the underlying channel.
- * @param {Object} message
- */
-u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) {
-  this.port_.postMessage(message);
-};
-
-
-/**
- * Emulates the HTML 5 addEventListener interface. Works only for the
- * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage.
- * @param {string} eventName
- * @param {function({data: Object})} handler
- */
-u2f.WrappedChromeRuntimePort_.prototype.addEventListener =
-    function(eventName, handler) {
-  var name = eventName.toLowerCase();
-  if (name == 'message' || name == 'onmessage') {
-    this.port_.onMessage.addListener(function(message) {
-      // Emulate a minimal MessageEvent object
-      handler({'data': message});
-    });
-  } else {
-    console.error('WrappedChromeRuntimePort only supports onMessage');
-  }
-};
-
-/**
- * Wrap the Authenticator app with a MessagePort interface.
- * @constructor
- * @private
- */
-u2f.WrappedAuthenticatorPort_ = function() {
-  this.requestId_ = -1;
-  this.requestObject_ = null;
-}
-
-/**
- * Launch the Authenticator intent.
- * @param {Object} message
- */
-u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) {
-  var intentUrl =
-    u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ +
-    ';S.request=' + encodeURIComponent(JSON.stringify(message)) +
-    ';end';
-  document.location = intentUrl;
-};
-
-/**
- * Tells what type of port this is.
- * @return {String} port type
- */
-u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() {
-  return "WrappedAuthenticatorPort_";
-};
-
-
-/**
- * Emulates the HTML 5 addEventListener interface.
- * @param {string} eventName
- * @param {function({data: Object})} handler
- */
-u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) {
-  var name = eventName.toLowerCase();
-  if (name == 'message') {
-    var self = this;
-    /* Register a callback to that executes when
-     * chrome injects the response. */
-    window.addEventListener(
-        'message', self.onRequestUpdate_.bind(self, handler), false);
-  } else {
-    console.error('WrappedAuthenticatorPort only supports message');
-  }
-};
-
-/**
- * Callback invoked  when a response is received from the Authenticator.
- * @param function({data: Object}) callback
- * @param {Object} message message Object
- */
-u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ =
-    function(callback, message) {
-  var messageObject = JSON.parse(message.data);
-  var intentUrl = messageObject['intentURL'];
-
-  var errorCode = messageObject['errorCode'];
-  var responseObject = null;
-  if (messageObject.hasOwnProperty('data')) {
-    responseObject = /** @type {Object} */ (
-        JSON.parse(messageObject['data']));
-  }
-
-  callback({'data': responseObject});
-};
-
-/**
- * Base URL for intents to Authenticator.
- * @const
- * @private
- */
-u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ =
-  'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE';
-
-/**
- * Wrap the iOS client app with a MessagePort interface.
- * @constructor
- * @private
- */
-u2f.WrappedIosPort_ = function() {};
-
-/**
- * Launch the iOS client app request
- * @param {Object} message
- */
-u2f.WrappedIosPort_.prototype.postMessage = function(message) {
-  var str = JSON.stringify(message);
-  var url = "u2f://auth?" + encodeURI(str);
-  location.replace(url);
-};
-
-/**
- * Tells what type of port this is.
- * @return {String} port type
- */
-u2f.WrappedIosPort_.prototype.getPortType = function() {
-  return "WrappedIosPort_";
-};
-
-/**
- * Emulates the HTML 5 addEventListener interface.
- * @param {string} eventName
- * @param {function({data: Object})} handler
- */
-u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) {
-  var name = eventName.toLowerCase();
-  if (name !== 'message') {
-    console.error('WrappedIosPort only supports message');
-  }
-};
-
-/**
- * Sets up an embedded trampoline iframe, sourced from the extension.
- * @param {function(MessagePort)} callback
- * @private
- */
-u2f.getIframePort_ = function(callback) {
-  // Create the iframe
-  var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID;
-  var iframe = document.createElement('iframe');
-  iframe.src = iframeOrigin + '/u2f-comms.html';
-  iframe.setAttribute('style', 'display:none');
-  document.body.appendChild(iframe);
-
-  var channel = new MessageChannel();
-  var ready = function(message) {
-    if (message.data == 'ready') {
-      channel.port1.removeEventListener('message', ready);
-      callback(channel.port1);
-    } else {
-      console.error('First event on iframe port was not "ready"');
-    }
-  };
-  channel.port1.addEventListener('message', ready);
-  channel.port1.start();
-
-  iframe.addEventListener('load', function() {
-    // Deliver the port to the iframe and initialize
-    iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]);
-  });
-};
-
-
-//High-level JS API
-
-/**
- * Default extension response timeout in seconds.
- * @const
- */
-u2f.EXTENSION_TIMEOUT_SEC = 30;
-
-/**
- * A singleton instance for a MessagePort to the extension.
- * @type {MessagePort|u2f.WrappedChromeRuntimePort_}
- * @private
- */
-u2f.port_ = null;
-
-/**
- * Callbacks waiting for a port
- * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>}
- * @private
- */
-u2f.waitingForPort_ = [];
-
-/**
- * A counter for requestIds.
- * @type {number}
- * @private
- */
-u2f.reqCounter_ = 0;
-
-/**
- * A map from requestIds to client callbacks
- * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse))
- *                       |function((u2f.Error|u2f.SignResponse)))>}
- * @private
- */
-u2f.callbackMap_ = {};
-
-/**
- * Creates or retrieves the MessagePort singleton to use.
- * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback
- * @private
- */
-u2f.getPortSingleton_ = function(callback) {
-  if (u2f.port_) {
-    callback(u2f.port_);
-  } else {
-    if (u2f.waitingForPort_.length == 0) {
-      u2f.getMessagePort(function(port) {
-        u2f.port_ = port;
-        u2f.port_.addEventListener('message',
-            /** @type {function(Event)} */ (u2f.responseHandler_));
-
-        // Careful, here be async callbacks. Maybe.
-        while (u2f.waitingForPort_.length)
-          u2f.waitingForPort_.shift()(u2f.port_);
-      });
-    }
-    u2f.waitingForPort_.push(callback);
-  }
-};
-
-/**
- * Handles response messages from the extension.
- * @param {MessageEvent.<u2f.Response>} message
- * @private
- */
-u2f.responseHandler_ = function(message) {
-  var response = message.data;
-  var reqId = response['requestId'];
-  if (!reqId || !u2f.callbackMap_[reqId]) {
-    console.error('Unknown or missing requestId in response.');
-    return;
-  }
-  var cb = u2f.callbackMap_[reqId];
-  delete u2f.callbackMap_[reqId];
-  cb(response['responseData']);
-};
-
-/**
- * Dispatches an array of sign requests to available U2F tokens.
- * If the JS API version supported by the extension is unknown, it first sends a
- * message to the extension to find out the supported API version and then it sends
- * the sign request.
- * @param {string=} appId
- * @param {string=} challenge
- * @param {Array<u2f.RegisteredKey>} registeredKeys
- * @param {function((u2f.Error|u2f.SignResponse))} callback
- * @param {number=} opt_timeoutSeconds
- */
-u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) {
-  if (js_api_version === undefined) {
-    // Send a message to get the extension to JS API version, then send the actual sign request.
-    u2f.getApiVersion(
-        function (response) {
-          js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version'];
-          console.log("Extension JS API Version: ", js_api_version);
-          u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds);
-        });
-  } else {
-    // We know the JS API version. Send the actual sign request in the supported API version.
-    u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds);
-  }
-};
-
-/**
- * Dispatches an array of sign requests to available U2F tokens.
- * @param {string=} appId
- * @param {string=} challenge
- * @param {Array<u2f.RegisteredKey>} registeredKeys
- * @param {function((u2f.Error|u2f.SignResponse))} callback
- * @param {number=} opt_timeoutSeconds
- */
-u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) {
-  u2f.getPortSingleton_(function(port) {
-    var reqId = ++u2f.reqCounter_;
-    u2f.callbackMap_[reqId] = callback;
-    var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ?
-        opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC);
-    var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId);
-    port.postMessage(req);
-  });
-};
-
-/**
- * Dispatches register requests to available U2F tokens. An array of sign
- * requests identifies already registered tokens.
- * If the JS API version supported by the extension is unknown, it first sends a
- * message to the extension to find out the supported API version and then it sends
- * the register request.
- * @param {string=} appId
- * @param {Array<u2f.RegisterRequest>} registerRequests
- * @param {Array<u2f.RegisteredKey>} registeredKeys
- * @param {function((u2f.Error|u2f.RegisterResponse))} callback
- * @param {number=} opt_timeoutSeconds
- */
-u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) {
-  if (js_api_version === undefined) {
-    // Send a message to get the extension to JS API version, then send the actual register request.
-    u2f.getApiVersion(
-        function (response) {
-          js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version'];
-          console.log("Extension JS API Version: ", js_api_version);
-          u2f.sendRegisterRequest(appId, registerRequests, registeredKeys,
-              callback, opt_timeoutSeconds);
-        });
-  } else {
-    // We know the JS API version. Send the actual register request in the supported API version.
-    u2f.sendRegisterRequest(appId, registerRequests, registeredKeys,
-        callback, opt_timeoutSeconds);
-  }
-};
-
-/**
- * Dispatches register requests to available U2F tokens. An array of sign
- * requests identifies already registered tokens.
- * @param {string=} appId
- * @param {Array<u2f.RegisterRequest>} registerRequests
- * @param {Array<u2f.RegisteredKey>} registeredKeys
- * @param {function((u2f.Error|u2f.RegisterResponse))} callback
- * @param {number=} opt_timeoutSeconds
- */
-u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) {
-  u2f.getPortSingleton_(function(port) {
-    var reqId = ++u2f.reqCounter_;
-    u2f.callbackMap_[reqId] = callback;
-    var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ?
-        opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC);
-    var req = u2f.formatRegisterRequest_(
-        appId, registeredKeys, registerRequests, timeoutSeconds, reqId);
-    port.postMessage(req);
-  });
-};
-
-
-/**
- * Dispatches a message to the extension to find out the supported
- * JS API version.
- * If the user is on a mobile phone and is thus using Google Authenticator instead
- * of the Chrome extension, don't send the request and simply return 0.
- * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback
- * @param {number=} opt_timeoutSeconds
- */
-u2f.getApiVersion = function(callback, opt_timeoutSeconds) {
- u2f.getPortSingleton_(function(port) {
-   // If we are using Android Google Authenticator or iOS client app,
-   // do not fire an intent to ask which JS API version to use.
-   if (port.getPortType) {
-     var apiVersion;
-     switch (port.getPortType()) {
-       case 'WrappedIosPort_':
-       case 'WrappedAuthenticatorPort_':
-         apiVersion = 1.1;
-         break;
-
-       default:
-         apiVersion = 0;
-         break;
-     }
-     callback({ 'js_api_version': apiVersion });
-     return;
-   }
-    var reqId = ++u2f.reqCounter_;
-    u2f.callbackMap_[reqId] = callback;
-    var req = {
-      type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST,
-      timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ?
-          opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC),
-      requestId: reqId
-    };
-    port.postMessage(req);
-  });
-};
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 4/7] www: switch to new tfa login format
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (20 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 3/7] use u2f-api.js and qrcode.min.js " Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 5/7] www: use af-address-book-o for realms Wolfgang Bumiller
                   ` (10 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 www/manager6/window/LoginWindow.js | 131 ++++++++++++++---------------
 1 file changed, 61 insertions(+), 70 deletions(-)

diff --git a/www/manager6/window/LoginWindow.js b/www/manager6/window/LoginWindow.js
index ecd198a5..4a07f75b 100644
--- a/www/manager6/window/LoginWindow.js
+++ b/www/manager6/window/LoginWindow.js
@@ -21,7 +21,7 @@ Ext.define('PVE.window.LoginWindow', {
 
 	xclass: 'Ext.app.ViewController',
 
-	onLogon: function() {
+	onLogon: async function() {
 	    var me = this;
 
 	    var form = this.lookupReference('loginForm');
@@ -70,30 +70,70 @@ Ext.define('PVE.window.LoginWindow', {
 	    }
 	    sp.set(saveunField.getStateId(), saveunField.getValue());
 
-	    form.submit({
-		failure: function(f, resp) {
-		    me.failure(resp);
-		},
-		success: function(f, resp) {
-		    view.el.unmask();
+	    try {
+		// Request updated authentication mechanism:
+		creds['new-format'] = 1;
 
-		    var data = resp.result.data;
-		    if (Ext.isDefined(data.NeedTFA)) {
-			// Store first factor login information first:
-			data.LoggedOut = true;
-			Proxmox.Utils.setAuthData(data);
-
-			if (Ext.isDefined(data.U2FChallenge)) {
-			    me.perform_u2f(data);
-			} else {
-			    me.perform_otp();
-			}
+		let resp = await Proxmox.Async.api2({
+		    url: '/api2/extjs/access/ticket',
+		    params: creds,
+		    method: 'POST',
+		});
+
+		let data = resp.result.data;
+		if (data.ticket.startsWith("PVE:!tfa!")) {
+		    // Store first factor login information first:
+		    data.LoggedOut = true;
+		    Proxmox.Utils.setAuthData(data);
+
+		    data = await me.performTFAChallenge(data);
+
+		    // Fill in what we copy over from the 1st factor:
+		    data.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
+		    data.username = Proxmox.UserName;
+		    me.success(data);
+		} else if (Ext.isDefined(data.NeedTFA)) {
+		    // Store first factor login information first:
+		    data.LoggedOut = true;
+		    Proxmox.Utils.setAuthData(data);
+
+		    if (Ext.isDefined(data.U2FChallenge)) {
+			me.perform_u2f(data);
 		    } else {
-			me.success(data);
+			me.perform_otp();
 		    }
-		},
+		} else {
+		    me.success(data);
+		}
+	    } catch (error) {
+		me.failure(error);
+	    }
+	},
+
+	/* START NEW TFA CODE (pbs copy) */
+	performTFAChallenge: async function(data) {
+	    let me = this;
+
+	    let userid = data.username;
+	    let ticket = data.ticket;
+	    let challenge = JSON.parse(decodeURIComponent(
+	        ticket.split(':')[1].slice("!tfa!".length),
+	    ));
+
+	    let resp = await new Promise((resolve, reject) => {
+		Ext.create('Proxmox.window.TfaLoginWindow', {
+		    userid,
+		    ticket,
+		    challenge,
+		    onResolve: value => resolve(value),
+		    onReject: reject,
+		}).show();
 	    });
+
+	    return resp.result.data;
 	},
+	/* END NEW TFA CODE (pbs copy) */
+
 	failure: function(resp) {
 	    var me = this;
 	    var view = me.getView();
@@ -151,7 +191,7 @@ Ext.define('PVE.window.LoginWindow', {
 		msg.close();
 		if (res.errorCode) {
 		    Proxmox.Utils.authClear();
-		    Ext.Msg.alert(gettext('Error'), Proxmox.Utils.render_u2f_error(res.errorCode));
+		    Ext.Msg.alert(gettext('Error'), PVE.Utils.render_u2f_error(res.errorCode));
 		    return;
 		}
 		delete res.errorCode;
@@ -356,52 +396,3 @@ Ext.define('PVE.window.LoginWindow', {
 	],
     }],
  });
-Ext.define('PVE.window.TFALoginWindow', {
-    extend: 'Ext.window.Window',
-
-    modal: true,
-    resizable: false,
-    title: 'Two-Factor Authentication',
-    layout: 'form',
-    defaultButton: 'loginButton',
-    defaultFocus: 'otpField',
-
-    controller: {
-	xclass: 'Ext.app.ViewController',
-	login: function() {
-	    var me = this;
-	    var view = me.getView();
-	    view.onLogin(me.lookup('otpField').getValue());
-	    view.close();
-	},
-	cancel: function() {
-	    var me = this;
-	    var view = me.getView();
-	    view.onCancel();
-	    view.close();
-	},
-    },
-
-    items: [
-	{
-	    xtype: 'textfield',
-	    fieldLabel: gettext('Please enter your OTP verification code:'),
-	    name: 'otp',
-	    itemId: 'otpField',
-	    reference: 'otpField',
-	    allowBlank: false,
-	},
-    ],
-
-    buttons: [
-	{
-	    text: gettext('Login'),
-	    reference: 'loginButton',
-	    handler: 'login',
-	},
-	{
-	    text: gettext('Cancel'),
-	    handler: 'cancel',
-	},
-    ],
-});
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 5/7] www: use af-address-book-o for realms
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (21 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 4/7] www: switch to new tfa login format Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 6/7] www: add TFA view to config Wolfgang Bumiller
                   ` (9 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

we do this in PBS, and use the key for TFA

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 www/manager6/dc/Config.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 934952d9..58038905 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -121,7 +121,7 @@ Ext.define('PVE.dc.Config', {
 		xtype: 'pveAuthView',
 		title: gettext('Authentication'),
 		groups: ['permissions'],
-		iconCls: 'fa fa-key',
+		iconCls: 'fa fa-address-book-o',
 		itemId: 'domains',
 	    },
 	    {
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 6/7] www: add TFA view to config
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (22 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 5/7] www: use af-address-book-o for realms Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 7/7] www: redirect user TFA button to TFA view Wolfgang Bumiller
                   ` (8 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 www/manager6/dc/Config.js | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 58038905..63a3d1d1 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -95,6 +95,16 @@ Ext.define('PVE.dc.Config', {
 	    itemId: 'apitokens',
 	});
 
+	me.items.push({
+	    xtype: 'pmxTfaView',
+	    title: gettext('Two Factor'),
+	    groups: ['permissions'],
+	    iconCls: 'fa fa-key',
+	    itemId: 'tfa',
+	    yubicoEnabled: true,
+	    issuerName: 'Proxmox VE',
+	});
+
 	if (caps.dc['Sys.Audit']) {
 	    me.items.push({
 		xtype: 'pveGroupView',
@@ -119,7 +129,7 @@ Ext.define('PVE.dc.Config', {
 	    },
 	    {
 		xtype: 'pveAuthView',
-		title: gettext('Authentication'),
+		title: gettext('Realms'),
 		groups: ['permissions'],
 		iconCls: 'fa fa-address-book-o',
 		itemId: 'domains',
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH manager 7/7] www: redirect user TFA button to TFA view
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (23 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 6/7] www: add TFA view to config Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 1/7] add pmxUserSelector Wolfgang Bumiller
                   ` (7 subsequent siblings)
  32 siblings, 0 replies; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 www/manager6/Makefile         |   1 -
 www/manager6/StateProvider.js |   1 +
 www/manager6/Workspace.js     |   6 +-
 www/manager6/dc/TFAEdit.js    | 545 ----------------------------------
 www/manager6/dc/UserView.js   |  27 --
 5 files changed, 3 insertions(+), 577 deletions(-)
 delete mode 100644 www/manager6/dc/TFAEdit.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 4011d4e5..584c1f2a 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -149,7 +149,6 @@ JSSRC= 							\
 	dc/Summary.js					\
 	dc/Support.js					\
 	dc/SyncWindow.js				\
-	dc/TFAEdit.js					\
 	dc/Tasks.js					\
 	dc/TokenEdit.js					\
 	dc/TokenView.js					\
diff --git a/www/manager6/StateProvider.js b/www/manager6/StateProvider.js
index e835f402..fafbb112 100644
--- a/www/manager6/StateProvider.js
+++ b/www/manager6/StateProvider.js
@@ -47,6 +47,7 @@ Ext.define('PVE.StateProvider', {
     hprefix: 'v1',
 
     compDict: {
+        tfa: 54,
 	sdn: 53,
 	cloudinit: 52,
 	replication: 51,
diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 0e2a750b..37d772b8 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -386,10 +386,8 @@ Ext.define('PVE.StdWorkspace', {
 				    itemId: 'tfaitem',
 				    iconCls: 'fa fa-fw fa-lock',
 				    handler: function(btn, event, rec) {
-					var win = Ext.create('PVE.window.TFAEdit', {
-					    userid: Proxmox.UserName,
-					});
-					win.show();
+					Ext.state.Manager.getProvider().set('dctab', { value: 'tfa' }, true);
+					me.selectById('root');
 				    },
 				},
 				{
diff --git a/www/manager6/dc/TFAEdit.js b/www/manager6/dc/TFAEdit.js
deleted file mode 100644
index 57a73b39..00000000
--- a/www/manager6/dc/TFAEdit.js
+++ /dev/null
@@ -1,545 +0,0 @@
-/*global u2f,QRCode*/
-Ext.define('PVE.window.TFAEdit', {
-    extend: 'Ext.window.Window',
-    mixins: ['Proxmox.Mixin.CBind'],
-
-    onlineHelp: 'pveum_tfa_auth', // fake to ensure this gets a link target
-
-    modal: true,
-    resizable: false,
-    title: gettext('Two Factor Authentication'),
-    subject: 'TFA',
-    url: '/api2/extjs/access/tfa',
-    width: 512,
-
-    layout: {
-	type: 'vbox',
-	align: 'stretch',
-    },
-
-    updateQrCode: function() {
-	var me = this;
-	var values = me.lookup('totp_form').getValues();
-	var algorithm = values.algorithm;
-	if (!algorithm) {
-	    algorithm = 'SHA1';
-	}
-
-	me.qrcode.makeCode(
-	    'otpauth://totp/' +
-	    encodeURIComponent(values.issuer) +
-	    ':' +
-	    encodeURIComponent(me.userid) +
-	    '?secret=' + values.secret +
-	    '&period=' + values.step +
-	    '&digits=' + values.digits +
-	    '&algorithm=' + algorithm +
-	    '&issuer=' + encodeURIComponent(values.issuer),
-	);
-
-	me.lookup('challenge').setVisible(true);
-	me.down('#qrbox').setVisible(true);
-    },
-
-    showError: function(error) {
-	Ext.Msg.alert(
-	    gettext('Error'),
-	    Proxmox.Utils.render_u2f_error(error),
-	);
-    },
-
-    doU2FChallenge: function(res) {
-	let me = this;
-
-	let challenge = res.result.data;
-	me.lookup('password').setDisabled(true);
-	let msg = Ext.Msg.show({
-	    title: 'U2F: ' + gettext('Setup'),
-	    message: gettext('Please press the button on your U2F Device'),
-	    buttons: [],
-	});
-	Ext.Function.defer(function() {
-	    u2f.register(challenge.appId, [challenge], [], function(response) {
-		msg.close();
-		if (response.errorCode) {
-		    me.showError(response.errorCode);
-		} else {
-		    me.respondToU2FChallenge(response);
-		}
-	    });
-	}, 500, me);
-    },
-
-    respondToU2FChallenge: function(data) {
-	var me = this;
-	var params = {
-	    userid: me.userid,
-	    action: 'confirm',
-	    response: JSON.stringify(data),
-	};
-	if (Proxmox.UserName !== 'root@pam') {
-	    params.password = me.lookup('password').value;
-	}
-	Proxmox.Utils.API2Request({
-	    url: '/api2/extjs/access/tfa',
-	    params: params,
-	    method: 'PUT',
-	    success: function() {
-		me.close();
-		Ext.Msg.show({
-		    title: gettext('Success'),
-		    message: gettext('U2F Device successfully connected.'),
-		    buttons: Ext.Msg.OK,
-		});
-	    },
-	    failure: function(response, opts) {
-		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
-	    },
-	});
-    },
-
-    viewModel: {
-	data: {
-	    in_totp_tab: true,
-	    tfa_required: false,
-	    tfa_type: null, // dependencies of formulas should not be undefined
-	    valid: false,
-	    u2f_available: true,
-	    secret: "",
-	},
-	formulas: {
-	    showTOTPVerifiction: function(get) {
-		return get('secret').length > 0 && get('canSetupTOTP');
-	    },
-	    canDeleteTFA: function(get) {
-		return get('tfa_type') !== null && !get('tfa_required');
-	    },
-	    canSetupTOTP: function(get) {
-		var tfa = get('tfa_type');
-		return tfa === null || tfa === 'totp' || tfa === 1;
-	    },
-	    canSetupU2F: function(get) {
-		var tfa = get('tfa_type');
-		return get('u2f_available') && (tfa === null || tfa === 'u2f' || tfa === 1);
-	    },
-	    secretEmpty: function(get) {
-		return get('secret').length === 0;
-	    },
-	    selectedTab: function(get) {
-		return (get('tfa_type') || 'totp') + '-panel';
-	    },
-	},
-    },
-
-    afterLoading: function(realm_tfa_type, user_tfa_type) {
-	var me = this;
-	var viewmodel = me.getViewModel();
-	if (user_tfa_type === 'oath') {
-	    user_tfa_type = 'totp';
-	    viewmodel.set('secret', '');
-	}
-
-	// if the user has no tfa, generate a secret for him
-	if (!user_tfa_type) {
-	    me.getController().randomizeSecret();
-	}
-
-	viewmodel.set('tfa_type', user_tfa_type || null);
-	if (!realm_tfa_type) {
-	    // There's no TFA enforced by the realm, everything works.
-	    viewmodel.set('u2f_available', true);
-	    viewmodel.set('tfa_required', false);
-	} else if (realm_tfa_type === 'oath') {
-	    // The realm explicitly requires TOTP
-	    if (user_tfa_type !== 'totp' && user_tfa_type !== null) {
-		// user had a different tfa method, so
-		// we have to change back to the totp tab and
-		// generate a secret
-		viewmodel.set('tfa_type', 'totp');
-		me.getController().randomizeSecret();
-	    }
-	    viewmodel.set('tfa_required', true);
-	    viewmodel.set('u2f_available', false);
-	} else {
-	    // The realm enforces some other TFA type (yubico)
-	    me.close();
-	    Ext.Msg.alert(
-		gettext('Error'),
-		Ext.String.format(
-		    gettext("Custom 2nd factor configuration is not supported on realms with '{0}' TFA."),
-		    realm_tfa_type,
-		),
-	    );
-	}
-    },
-
-    controller: {
-	xclass: 'Ext.app.ViewController',
-	control: {
-	    'field[qrupdate=true]': {
-		change: function() {
-		    this.getView().updateQrCode();
-		},
-	    },
-	    'field': {
-		validitychange: function(field, valid) {
-		    var me = this;
-		    var viewModel = me.getViewModel();
-		    var form = me.lookup('totp_form');
-		    var challenge = me.lookup('challenge');
-		    var password = me.lookup('password');
-		    viewModel.set('valid', form.isValid() && challenge.isValid() && password.isValid());
-		},
-	    },
-	    '#': {
-		show: function() {
-		    let view = this.getView();
-
-		    Proxmox.Utils.API2Request({
-			url: '/access/users/' + encodeURIComponent(view.userid) + '/tfa',
-			waitMsgTarget: view.down('#tfatabs'),
-			method: 'GET',
-			success: function(response, opts) {
-			    let data = response.result.data;
-			    view.afterLoading(data.realm, data.user);
-			},
-			failure: function(response, opts) {
-			    view.close();
-			    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
-			},
-		    });
-
-		    view.qrdiv = document.createElement('center');
-		    view.qrcode = new QRCode(view.qrdiv, {
-			width: 256,
-			height: 256,
-			correctLevel: QRCode.CorrectLevel.M,
-		    });
-		    view.down('#qrbox').getEl().appendChild(view.qrdiv);
-
-		    if (Proxmox.UserName === 'root@pam') {
-			view.lookup('password').setVisible(false);
-			view.lookup('password').setDisabled(true);
-		    }
-		},
-	    },
-	    '#tfatabs': {
-		tabchange: function(panel, newcard) {
-		    this.getViewModel().set('in_totp_tab', newcard.itemId === 'totp-panel');
-		},
-	    },
-	},
-
-	applySettings: function() {
-	    let me = this;
-	    let values = me.lookup('totp_form').getValues();
-	    let params = {
-		userid: me.getView().userid,
-		action: 'new',
-		key: 'v2-' + values.secret,
-		config: PVE.Parser.printPropertyString({
-		    type: 'oath',
-		    digits: values.digits,
-		    step: values.step,
-		}),
-		// this is used to verify that the client generates the correct codes:
-		response: me.lookup('challenge').value,
-	    };
-
-	    if (Proxmox.UserName !== 'root@pam') {
-		params.password = me.lookup('password').value;
-	    }
-
-	    Proxmox.Utils.API2Request({
-		url: '/api2/extjs/access/tfa',
-		params: params,
-		method: 'PUT',
-		waitMsgTarget: me.getView(),
-		success: function(response, opts) {
-		    me.getView().close();
-		},
-		failure: function(response, opts) {
-		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
-		},
-	    });
-	},
-
-	deleteTFA: function() {
-	    let me = this;
-	    let params = {
-		userid: me.getView().userid,
-		action: 'delete',
-	    };
-
-	    if (Proxmox.UserName !== 'root@pam') {
-		params.password = me.lookup('password').value;
-	    }
-
-	    Proxmox.Utils.API2Request({
-		url: '/api2/extjs/access/tfa',
-		params: params,
-		method: 'PUT',
-		waitMsgTarget: me.getView(),
-		success: function(response, opts) {
-		    me.getView().close();
-		},
-		failure: function(response, opts) {
-		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
-		},
-	    });
-	},
-
-	randomizeSecret: function() {
-	    let me = this;
-	    let rnd = new Uint8Array(32);
-	    window.crypto.getRandomValues(rnd);
-	    let data = '';
-	    rnd.forEach(function(b) {
-		// secret must be base32, so just use the first 5 bits
-		b = b & 0x1f;
-		if (b < 26) {
-		    data += String.fromCharCode(b + 0x41); // A..Z
-		} else {
-		    data += String.fromCharCode(b-26 + 0x32); // 2..7
-		}
-	    });
-	    me.getViewModel().set('secret', data);
-	},
-
-	startU2FRegistration: function() {
-	    let me = this;
-
-	    let params = {
-		userid: me.getView().userid,
-		action: 'new',
-	    };
-
-	    if (Proxmox.UserName !== 'root@pam') {
-		params.password = me.lookup('password').value;
-	    }
-
-	    Proxmox.Utils.API2Request({
-		url: '/api2/extjs/access/tfa',
-		params: params,
-		method: 'PUT',
-		waitMsgTarget: me.getView(),
-		success: function(response) {
-		    me.getView().doU2FChallenge(response);
-		},
-		failure: function(response, opts) {
-		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
-		},
-	    });
-	},
-    },
-
-    items: [
-	{
-	    xtype: 'tabpanel',
-	    itemId: 'tfatabs',
-	    reference: 'tfatabs',
-	    border: false,
-	    bind: {
-		activeTab: '{selectedTab}',
-	    },
-	    items: [
-		{
-		    xtype: 'panel',
-		    title: 'TOTP',
-		    itemId: 'totp-panel',
-		    reference: 'totp_panel',
-		    tfa_type: 'totp',
-		    border: false,
-		    bind: {
-			disabled: '{!canSetupTOTP}',
-		    },
-		    layout: {
-			type: 'vbox',
-			align: 'stretch',
-		    },
-		    items: [
-			{
-			    xtype: 'form',
-			    layout: 'anchor',
-			    border: false,
-			    reference: 'totp_form',
-			    fieldDefaults: {
-				anchor: '100%',
-				padding: '0 5',
-			    },
-			    items: [
-				{
-				    xtype: 'displayfield',
-				    fieldLabel: gettext('User name'),
-				    renderer: Ext.String.htmlEncode,
-				    cbind: {
-					value: '{userid}',
-				    },
-				},
-				{
-				    layout: 'hbox',
-				    border: false,
-				    padding: '0 0 5 0',
-				    items: [{
-					xtype: 'textfield',
-					fieldLabel: gettext('Secret'),
-					emptyText: gettext('Unchanged'),
-					name: 'secret',
-					reference: 'tfa_secret',
-					regex: /^[A-Z2-7=]+$/,
-					regexText: 'Must be base32 [A-Z2-7=]',
-					maskRe: /[A-Z2-7=]/,
-					qrupdate: true,
-					bind: {
-					    value: "{secret}",
-					},
-					flex: 4,
-				    },
-				    {
-					xtype: 'button',
-					text: gettext('Randomize'),
-					reference: 'randomize_button',
-					handler: 'randomizeSecret',
-					flex: 1,
-				    }],
-				},
-				{
-				    xtype: 'numberfield',
-				    fieldLabel: gettext('Time period'),
-				    name: 'step',
-				    // Google Authenticator ignores this and generates bogus data
-				    hidden: true,
-				    value: 30,
-				    minValue: 10,
-				    qrupdate: true,
-				},
-				{
-				    xtype: 'numberfield',
-				    fieldLabel: gettext('Digits'),
-				    name: 'digits',
-				    value: 6,
-				    // Google Authenticator ignores this and generates bogus data
-				    hidden: true,
-				    minValue: 6,
-				    maxValue: 8,
-				    qrupdate: true,
-				},
-				{
-				    xtype: 'textfield',
-				    fieldLabel: gettext('Issuer Name'),
-				    name: 'issuer',
-				    value: 'Proxmox Web UI',
-				    qrupdate: true,
-				},
-			    ],
-			},
-			{
-			    xtype: 'box',
-			    itemId: 'qrbox',
-			    visible: false, // will be enabled when generating a qr code
-			    bind: {
-				visible: '{!secretEmpty}',
-			    },
-			    style: {
-				'background-color': 'white',
-				padding: '5px',
-				width: '266px',
-				height: '266px',
-			    },
-			},
-			{
-			    xtype: 'textfield',
-			    fieldLabel: gettext('Verification Code'),
-			    allowBlank: false,
-			    reference: 'challenge',
-			    bind: {
-				disabled: '{!showTOTPVerifiction}',
-				visible: '{showTOTPVerifiction}',
-			    },
-			    padding: '0 5',
-			    emptyText: gettext('Scan QR code and enter TOTP auth. code to verify'),
-			},
-		    ],
-		},
-		{
-		    title: 'U2F',
-		    itemId: 'u2f-panel',
-		    reference: 'u2f_panel',
-		    tfa_type: 'u2f',
-		    border: false,
-		    padding: '5 5',
-		    layout: {
-			type: 'vbox',
-			align: 'middle',
-		    },
-		    bind: {
-			disabled: '{!canSetupU2F}',
-		    },
-		    items: [
-			{
-			    xtype: 'label',
-			    width: 500,
-			    text: gettext('To register a U2F device, connect the device, then click the button and follow the instructions.'),
-			},
-		    ],
-		},
-	    ],
-	},
-	{
-	    xtype: 'textfield',
-	    inputType: 'password',
-	    fieldLabel: gettext('Password'),
-	    minLength: 5,
-	    reference: 'password',
-	    allowBlank: false,
-	    validateBlank: true,
-	    padding: '0 0 5 5',
-	    emptyText: gettext('verify current password'),
-	},
-    ],
-
-    buttons: [
-	{
-	    xtype: 'proxmoxHelpButton',
-	},
-	'->',
-	{
-	    text: gettext('Apply'),
-	    handler: 'applySettings',
-	    bind: {
-		hidden: '{!in_totp_tab}',
-		disabled: '{!valid}',
-	    },
-	},
-	{
-	    xtype: 'button',
-	    text: gettext('Register U2F Device'),
-	    handler: 'startU2FRegistration',
-	    bind: {
-		hidden: '{in_totp_tab}',
-		disabled: '{tfa_type}',
-	    },
-	},
-	{
-	    text: gettext('Delete'),
-	    reference: 'delete_button',
-	    disabled: true,
-	    handler: 'deleteTFA',
-	    bind: {
-		disabled: '{!canDeleteTFA}',
-	    },
-	},
-    ],
-
-    initComponent: function() {
-	var me = this;
-
-	if (!me.userid) {
-	    throw "no userid given";
-	}
-
-	me.callParent();
-
-	Ext.GlobalEvents.fireEvent('proxmoxShowHelp', 'pveum_tfa_auth');
-    },
-});
diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js
index 9c84bf7d..f397731d 100644
--- a/www/manager6/dc/UserView.js
+++ b/www/manager6/dc/UserView.js
@@ -77,32 +77,6 @@ Ext.define('PVE.dc.UserView', {
 		});
 	    },
 	});
-	let tfachange_btn = new Proxmox.button.Button({
-	    text: 'TFA',
-	    disabled: true,
-	    selModel: sm,
-	    enableFn: function(record) {
-		let type = record.data['realm-type'];
-		if (type) {
-		    if (PVE.Utils.authSchema[type]) {
-			return !!PVE.Utils.authSchema[type].tfa;
-		    }
-		}
-		return false;
-	    },
-	    handler: function(btn, event, rec) {
-		var d = rec.data;
-		var tfa_type = PVE.Parser.parseTfaType(d.keys);
-		Ext.create('PVE.window.TFAEdit', {
-		    tfa_type: tfa_type,
-		    userid: d.userid,
-		    autoShow: true,
-		    listeners: {
-			destroy: () => reload(),
-		    },
-		});
-	    },
-	});
 
 	var perm_btn = new Proxmox.button.Button({
 	    text: gettext('Permissions'),
@@ -140,7 +114,6 @@ Ext.define('PVE.dc.UserView', {
 		remove_btn,
 		'-',
 		pwchange_btn,
-		tfachange_btn,
 		'-',
 		perm_btn,
 	    ],
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 1/7] add pmxUserSelector
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (24 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH manager 7/7] www: redirect user TFA button to TFA view Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:29   ` [pve-devel] applied: " Dominik Csapak
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn Wolfgang Bumiller
                   ` (6 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

copied from pbs

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Makefile             |  1 +
 src/form/UserSelector.js | 50 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 51 insertions(+)
 create mode 100644 src/form/UserSelector.js

diff --git a/src/Makefile b/src/Makefile
index a490ccd..cc464c3 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -42,6 +42,7 @@ JSSRC=					\
 	form/MultiDiskSelector.js	\
 	form/TaskTypeSelector.js	\
 	form/ACME.js			\
+	form/UserSelector.js		\
 	button/Button.js		\
 	button/HelpButton.js		\
 	grid/ObjectGrid.js		\
diff --git a/src/form/UserSelector.js b/src/form/UserSelector.js
new file mode 100644
index 0000000..ce66fab
--- /dev/null
+++ b/src/form/UserSelector.js
@@ -0,0 +1,50 @@
+Ext.define('Proxmox.form.UserSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: 'widget.pmxUserSelector',
+
+    allowBlank: false,
+    autoSelect: false,
+    valueField: 'userid',
+    displayField: 'userid',
+
+    editable: true,
+    anyMatch: true,
+    forceSelection: true,
+
+    store: {
+	model: 'pmx-users',
+	autoLoad: true,
+	params: {
+	    enabled: 1,
+	},
+	sorters: 'userid',
+    },
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('User'),
+		sortable: true,
+		dataIndex: 'userid',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Name'),
+		sortable: true,
+		renderer: (first, mD, rec) => Ext.String.htmlEncode(
+		    `${first || ''} ${rec.data.lastname || ''}`,
+		),
+		dataIndex: 'firstname',
+		flex: 1,
+	    },
+	    {
+		header: gettext('Comment'),
+		sortable: false,
+		dataIndex: 'comment',
+		renderer: Ext.String.htmlEncode,
+		flex: 1,
+	    },
+	],
+    },
+});
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (25 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 1/7] add pmxUserSelector Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js Wolfgang Bumiller
                   ` (5 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

base64url parts copied from pbs
render_u2f_error copied from pve

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Utils.js | 45 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

diff --git a/src/Utils.js b/src/Utils.js
index c52bef2..9703bd9 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1172,6 +1172,51 @@ utilities: {
 
 	return Proxmox.Utils.unknownText;
     },
+
+    render_u2f_error: function(error) {
+	var ErrorNames = {
+	    '1': gettext('Other Error'),
+	    '2': gettext('Bad Request'),
+	    '3': gettext('Configuration Unsupported'),
+	    '4': gettext('Device Ineligible'),
+	    '5': gettext('Timeout'),
+	};
+	return "U2F Error: " + ErrorNames[error] || Proxmox.Utils.unknownText;
+    },
+
+    // Convert an ArrayBuffer to a base64url encoded string.
+    // A `null` value will be preserved for convenience.
+    bytes_to_base64url: function(bytes) {
+	if (bytes === null) {
+	    return null;
+	}
+
+	return btoa(Array
+	    .from(new Uint8Array(bytes))
+	    .map(val => String.fromCharCode(val))
+	    .join(''),
+	)
+	.replace(/\+/g, '-')
+	.replace(/\//g, '_')
+	.replace(/[=]/g, '');
+    },
+
+    // Convert an a base64url string to an ArrayBuffer.
+    // A `null` value will be preserved for convenience.
+    base64url_to_bytes: function(b64u) {
+	if (b64u === null) {
+	    return null;
+	}
+
+	return new Uint8Array(
+	    atob(b64u
+		.replace(/-/g, '+')
+		.replace(/_/g, '/'),
+	    )
+	    .split('')
+	    .map(val => val.charCodeAt(0)),
+	);
+    },
 },
 
     singleton: true,
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (26 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:31   ` Dominik Csapak
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow Wolfgang Bumiller
                   ` (4 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

copied from pve/pbs

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Makefile      |   2 +
 src/qrcode.min.js |   1 +
 src/u2f-api.js    | 748 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 751 insertions(+)
 create mode 100644 src/qrcode.min.js
 create mode 100644 src/u2f-api.js

diff --git a/src/Makefile b/src/Makefile
index cc464c3..fe915dd 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -113,6 +113,8 @@ proxmoxlib.js: .lint-incremental ${JSSRC}
 install: proxmoxlib.js
 	install -d -m 755 ${WWWBASEDIR}
 	install -m 0644 proxmoxlib.js ${WWWBASEDIR}
+	install -m 0644 u2f-api.js ${WWWBASEDIR}
+	install -m 0644 qrcode.min.js ${WWWBASEDIR}
 	set -e && for i in ${SUBDIRS}; do ${MAKE} -C $$i $@; done
 
 .PHONY: clean
diff --git a/src/qrcode.min.js b/src/qrcode.min.js
new file mode 100644
index 0000000..993e88f
--- /dev/null
+++ b/src/qrcode.min.js
@@ -0,0 +1 @@
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
\ No newline at end of file
diff --git a/src/u2f-api.js b/src/u2f-api.js
new file mode 100644
index 0000000..9244d14
--- /dev/null
+++ b/src/u2f-api.js
@@ -0,0 +1,748 @@
+//Copyright 2014-2015 Google Inc. All rights reserved.
+
+//Use of this source code is governed by a BSD-style
+//license that can be found in the LICENSE file or at
+//https://developers.google.com/open-source/licenses/bsd
+
+/**
+ * @fileoverview The U2F api.
+ */
+'use strict';
+
+
+/**
+ * Namespace for the U2F api.
+ * @type {Object}
+ */
+var u2f = u2f || {};
+
+/**
+ * FIDO U2F Javascript API Version
+ * @number
+ */
+var js_api_version;
+
+/**
+ * The U2F extension id
+ * @const {string}
+ */
+// The Chrome packaged app extension ID.
+// Uncomment this if you want to deploy a server instance that uses
+// the package Chrome app and does not require installing the U2F Chrome extension.
+ u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd';
+// The U2F Chrome extension ID.
+// Uncomment this if you want to deploy a server instance that uses
+// the U2F Chrome extension to authenticate.
+// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne';
+
+
+/**
+ * Message types for messsages to/from the extension
+ * @const
+ * @enum {string}
+ */
+u2f.MessageTypes = {
+    'U2F_REGISTER_REQUEST': 'u2f_register_request',
+    'U2F_REGISTER_RESPONSE': 'u2f_register_response',
+    'U2F_SIGN_REQUEST': 'u2f_sign_request',
+    'U2F_SIGN_RESPONSE': 'u2f_sign_response',
+    'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request',
+    'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response'
+};
+
+
+/**
+ * Response status codes
+ * @const
+ * @enum {number}
+ */
+u2f.ErrorCodes = {
+    'OK': 0,
+    'OTHER_ERROR': 1,
+    'BAD_REQUEST': 2,
+    'CONFIGURATION_UNSUPPORTED': 3,
+    'DEVICE_INELIGIBLE': 4,
+    'TIMEOUT': 5
+};
+
+
+/**
+ * A message for registration requests
+ * @typedef {{
+ *   type: u2f.MessageTypes,
+ *   appId: ?string,
+ *   timeoutSeconds: ?number,
+ *   requestId: ?number
+ * }}
+ */
+u2f.U2fRequest;
+
+
+/**
+ * A message for registration responses
+ * @typedef {{
+ *   type: u2f.MessageTypes,
+ *   responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse),
+ *   requestId: ?number
+ * }}
+ */
+u2f.U2fResponse;
+
+
+/**
+ * An error object for responses
+ * @typedef {{
+ *   errorCode: u2f.ErrorCodes,
+ *   errorMessage: ?string
+ * }}
+ */
+u2f.Error;
+
+/**
+ * Data object for a single sign request.
+ * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}}
+ */
+u2f.Transport;
+
+
+/**
+ * Data object for a single sign request.
+ * @typedef {Array<u2f.Transport>}
+ */
+u2f.Transports;
+
+/**
+ * Data object for a single sign request.
+ * @typedef {{
+ *   version: string,
+ *   challenge: string,
+ *   keyHandle: string,
+ *   appId: string
+ * }}
+ */
+u2f.SignRequest;
+
+
+/**
+ * Data object for a sign response.
+ * @typedef {{
+ *   keyHandle: string,
+ *   signatureData: string,
+ *   clientData: string
+ * }}
+ */
+u2f.SignResponse;
+
+
+/**
+ * Data object for a registration request.
+ * @typedef {{
+ *   version: string,
+ *   challenge: string
+ * }}
+ */
+u2f.RegisterRequest;
+
+
+/**
+ * Data object for a registration response.
+ * @typedef {{
+ *   version: string,
+ *   keyHandle: string,
+ *   transports: Transports,
+ *   appId: string
+ * }}
+ */
+u2f.RegisterResponse;
+
+
+/**
+ * Data object for a registered key.
+ * @typedef {{
+ *   version: string,
+ *   keyHandle: string,
+ *   transports: ?Transports,
+ *   appId: ?string
+ * }}
+ */
+u2f.RegisteredKey;
+
+
+/**
+ * Data object for a get API register response.
+ * @typedef {{
+ *   js_api_version: number
+ * }}
+ */
+u2f.GetJsApiVersionResponse;
+
+
+//Low level MessagePort API support
+
+/**
+ * Sets up a MessagePort to the U2F extension using the
+ * available mechanisms.
+ * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback
+ */
+u2f.getMessagePort = function(callback) {
+  if (typeof chrome != 'undefined' && chrome.runtime) {
+    // The actual message here does not matter, but we need to get a reply
+    // for the callback to run. Thus, send an empty signature request
+    // in order to get a failure response.
+    var msg = {
+        type: u2f.MessageTypes.U2F_SIGN_REQUEST,
+        signRequests: []
+    };
+    chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() {
+      if (!chrome.runtime.lastError) {
+        // We are on a whitelisted origin and can talk directly
+        // with the extension.
+        u2f.getChromeRuntimePort_(callback);
+      } else {
+        // chrome.runtime was available, but we couldn't message
+        // the extension directly, use iframe
+        u2f.getIframePort_(callback);
+      }
+    });
+  } else if (u2f.isAndroidChrome_()) {
+    u2f.getAuthenticatorPort_(callback);
+  } else if (u2f.isIosChrome_()) {
+    u2f.getIosPort_(callback);
+  } else {
+    // chrome.runtime was not available at all, which is normal
+    // when this origin doesn't have access to any extensions.
+    u2f.getIframePort_(callback);
+  }
+};
+
+/**
+ * Detect chrome running on android based on the browser's useragent.
+ * @private
+ */
+u2f.isAndroidChrome_ = function() {
+  var userAgent = navigator.userAgent;
+  return userAgent.indexOf('Chrome') != -1 &&
+  userAgent.indexOf('Android') != -1;
+};
+
+/**
+ * Detect chrome running on iOS based on the browser's platform.
+ * @private
+ */
+u2f.isIosChrome_ = function() {
+  return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1;
+};
+
+/**
+ * Connects directly to the extension via chrome.runtime.connect.
+ * @param {function(u2f.WrappedChromeRuntimePort_)} callback
+ * @private
+ */
+u2f.getChromeRuntimePort_ = function(callback) {
+  var port = chrome.runtime.connect(u2f.EXTENSION_ID,
+      {'includeTlsChannelId': true});
+  setTimeout(function() {
+    callback(new u2f.WrappedChromeRuntimePort_(port));
+  }, 0);
+};
+
+/**
+ * Return a 'port' abstraction to the Authenticator app.
+ * @param {function(u2f.WrappedAuthenticatorPort_)} callback
+ * @private
+ */
+u2f.getAuthenticatorPort_ = function(callback) {
+  setTimeout(function() {
+    callback(new u2f.WrappedAuthenticatorPort_());
+  }, 0);
+};
+
+/**
+ * Return a 'port' abstraction to the iOS client app.
+ * @param {function(u2f.WrappedIosPort_)} callback
+ * @private
+ */
+u2f.getIosPort_ = function(callback) {
+  setTimeout(function() {
+    callback(new u2f.WrappedIosPort_());
+  }, 0);
+};
+
+/**
+ * A wrapper for chrome.runtime.Port that is compatible with MessagePort.
+ * @param {Port} port
+ * @constructor
+ * @private
+ */
+u2f.WrappedChromeRuntimePort_ = function(port) {
+  this.port_ = port;
+};
+
+/**
+ * Format and return a sign request compliant with the JS API version supported by the extension.
+ * @param {Array<u2f.SignRequest>} signRequests
+ * @param {number} timeoutSeconds
+ * @param {number} reqId
+ * @return {Object}
+ */
+u2f.formatSignRequest_ =
+  function(appId, challenge, registeredKeys, timeoutSeconds, reqId) {
+  if (js_api_version === undefined || js_api_version < 1.1) {
+    // Adapt request to the 1.0 JS API
+    var signRequests = [];
+    for (var i = 0; i < registeredKeys.length; i++) {
+      signRequests[i] = {
+          version: registeredKeys[i].version,
+          challenge: challenge,
+          keyHandle: registeredKeys[i].keyHandle,
+          appId: appId
+      };
+    }
+    return {
+      type: u2f.MessageTypes.U2F_SIGN_REQUEST,
+      signRequests: signRequests,
+      timeoutSeconds: timeoutSeconds,
+      requestId: reqId
+    };
+  }
+  // JS 1.1 API
+  return {
+    type: u2f.MessageTypes.U2F_SIGN_REQUEST,
+    appId: appId,
+    challenge: challenge,
+    registeredKeys: registeredKeys,
+    timeoutSeconds: timeoutSeconds,
+    requestId: reqId
+  };
+};
+
+/**
+ * Format and return a register request compliant with the JS API version supported by the extension..
+ * @param {Array<u2f.SignRequest>} signRequests
+ * @param {Array<u2f.RegisterRequest>} signRequests
+ * @param {number} timeoutSeconds
+ * @param {number} reqId
+ * @return {Object}
+ */
+u2f.formatRegisterRequest_ =
+  function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) {
+  if (js_api_version === undefined || js_api_version < 1.1) {
+    // Adapt request to the 1.0 JS API
+    for (var i = 0; i < registerRequests.length; i++) {
+      registerRequests[i].appId = appId;
+    }
+    var signRequests = [];
+    for (var i = 0; i < registeredKeys.length; i++) {
+      signRequests[i] = {
+          version: registeredKeys[i].version,
+          challenge: registerRequests[0],
+          keyHandle: registeredKeys[i].keyHandle,
+          appId: appId
+      };
+    }
+    return {
+      type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
+      signRequests: signRequests,
+      registerRequests: registerRequests,
+      timeoutSeconds: timeoutSeconds,
+      requestId: reqId
+    };
+  }
+  // JS 1.1 API
+  return {
+    type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
+    appId: appId,
+    registerRequests: registerRequests,
+    registeredKeys: registeredKeys,
+    timeoutSeconds: timeoutSeconds,
+    requestId: reqId
+  };
+};
+
+
+/**
+ * Posts a message on the underlying channel.
+ * @param {Object} message
+ */
+u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) {
+  this.port_.postMessage(message);
+};
+
+
+/**
+ * Emulates the HTML 5 addEventListener interface. Works only for the
+ * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage.
+ * @param {string} eventName
+ * @param {function({data: Object})} handler
+ */
+u2f.WrappedChromeRuntimePort_.prototype.addEventListener =
+    function(eventName, handler) {
+  var name = eventName.toLowerCase();
+  if (name == 'message' || name == 'onmessage') {
+    this.port_.onMessage.addListener(function(message) {
+      // Emulate a minimal MessageEvent object
+      handler({'data': message});
+    });
+  } else {
+    console.error('WrappedChromeRuntimePort only supports onMessage');
+  }
+};
+
+/**
+ * Wrap the Authenticator app with a MessagePort interface.
+ * @constructor
+ * @private
+ */
+u2f.WrappedAuthenticatorPort_ = function() {
+  this.requestId_ = -1;
+  this.requestObject_ = null;
+}
+
+/**
+ * Launch the Authenticator intent.
+ * @param {Object} message
+ */
+u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) {
+  var intentUrl =
+    u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ +
+    ';S.request=' + encodeURIComponent(JSON.stringify(message)) +
+    ';end';
+  document.location = intentUrl;
+};
+
+/**
+ * Tells what type of port this is.
+ * @return {String} port type
+ */
+u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() {
+  return "WrappedAuthenticatorPort_";
+};
+
+
+/**
+ * Emulates the HTML 5 addEventListener interface.
+ * @param {string} eventName
+ * @param {function({data: Object})} handler
+ */
+u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) {
+  var name = eventName.toLowerCase();
+  if (name == 'message') {
+    var self = this;
+    /* Register a callback to that executes when
+     * chrome injects the response. */
+    window.addEventListener(
+        'message', self.onRequestUpdate_.bind(self, handler), false);
+  } else {
+    console.error('WrappedAuthenticatorPort only supports message');
+  }
+};
+
+/**
+ * Callback invoked  when a response is received from the Authenticator.
+ * @param function({data: Object}) callback
+ * @param {Object} message message Object
+ */
+u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ =
+    function(callback, message) {
+  var messageObject = JSON.parse(message.data);
+  var intentUrl = messageObject['intentURL'];
+
+  var errorCode = messageObject['errorCode'];
+  var responseObject = null;
+  if (messageObject.hasOwnProperty('data')) {
+    responseObject = /** @type {Object} */ (
+        JSON.parse(messageObject['data']));
+  }
+
+  callback({'data': responseObject});
+};
+
+/**
+ * Base URL for intents to Authenticator.
+ * @const
+ * @private
+ */
+u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ =
+  'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE';
+
+/**
+ * Wrap the iOS client app with a MessagePort interface.
+ * @constructor
+ * @private
+ */
+u2f.WrappedIosPort_ = function() {};
+
+/**
+ * Launch the iOS client app request
+ * @param {Object} message
+ */
+u2f.WrappedIosPort_.prototype.postMessage = function(message) {
+  var str = JSON.stringify(message);
+  var url = "u2f://auth?" + encodeURI(str);
+  location.replace(url);
+};
+
+/**
+ * Tells what type of port this is.
+ * @return {String} port type
+ */
+u2f.WrappedIosPort_.prototype.getPortType = function() {
+  return "WrappedIosPort_";
+};
+
+/**
+ * Emulates the HTML 5 addEventListener interface.
+ * @param {string} eventName
+ * @param {function({data: Object})} handler
+ */
+u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) {
+  var name = eventName.toLowerCase();
+  if (name !== 'message') {
+    console.error('WrappedIosPort only supports message');
+  }
+};
+
+/**
+ * Sets up an embedded trampoline iframe, sourced from the extension.
+ * @param {function(MessagePort)} callback
+ * @private
+ */
+u2f.getIframePort_ = function(callback) {
+  // Create the iframe
+  var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID;
+  var iframe = document.createElement('iframe');
+  iframe.src = iframeOrigin + '/u2f-comms.html';
+  iframe.setAttribute('style', 'display:none');
+  document.body.appendChild(iframe);
+
+  var channel = new MessageChannel();
+  var ready = function(message) {
+    if (message.data == 'ready') {
+      channel.port1.removeEventListener('message', ready);
+      callback(channel.port1);
+    } else {
+      console.error('First event on iframe port was not "ready"');
+    }
+  };
+  channel.port1.addEventListener('message', ready);
+  channel.port1.start();
+
+  iframe.addEventListener('load', function() {
+    // Deliver the port to the iframe and initialize
+    iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]);
+  });
+};
+
+
+//High-level JS API
+
+/**
+ * Default extension response timeout in seconds.
+ * @const
+ */
+u2f.EXTENSION_TIMEOUT_SEC = 30;
+
+/**
+ * A singleton instance for a MessagePort to the extension.
+ * @type {MessagePort|u2f.WrappedChromeRuntimePort_}
+ * @private
+ */
+u2f.port_ = null;
+
+/**
+ * Callbacks waiting for a port
+ * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>}
+ * @private
+ */
+u2f.waitingForPort_ = [];
+
+/**
+ * A counter for requestIds.
+ * @type {number}
+ * @private
+ */
+u2f.reqCounter_ = 0;
+
+/**
+ * A map from requestIds to client callbacks
+ * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse))
+ *                       |function((u2f.Error|u2f.SignResponse)))>}
+ * @private
+ */
+u2f.callbackMap_ = {};
+
+/**
+ * Creates or retrieves the MessagePort singleton to use.
+ * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback
+ * @private
+ */
+u2f.getPortSingleton_ = function(callback) {
+  if (u2f.port_) {
+    callback(u2f.port_);
+  } else {
+    if (u2f.waitingForPort_.length == 0) {
+      u2f.getMessagePort(function(port) {
+        u2f.port_ = port;
+        u2f.port_.addEventListener('message',
+            /** @type {function(Event)} */ (u2f.responseHandler_));
+
+        // Careful, here be async callbacks. Maybe.
+        while (u2f.waitingForPort_.length)
+          u2f.waitingForPort_.shift()(u2f.port_);
+      });
+    }
+    u2f.waitingForPort_.push(callback);
+  }
+};
+
+/**
+ * Handles response messages from the extension.
+ * @param {MessageEvent.<u2f.Response>} message
+ * @private
+ */
+u2f.responseHandler_ = function(message) {
+  var response = message.data;
+  var reqId = response['requestId'];
+  if (!reqId || !u2f.callbackMap_[reqId]) {
+    console.error('Unknown or missing requestId in response.');
+    return;
+  }
+  var cb = u2f.callbackMap_[reqId];
+  delete u2f.callbackMap_[reqId];
+  cb(response['responseData']);
+};
+
+/**
+ * Dispatches an array of sign requests to available U2F tokens.
+ * If the JS API version supported by the extension is unknown, it first sends a
+ * message to the extension to find out the supported API version and then it sends
+ * the sign request.
+ * @param {string=} appId
+ * @param {string=} challenge
+ * @param {Array<u2f.RegisteredKey>} registeredKeys
+ * @param {function((u2f.Error|u2f.SignResponse))} callback
+ * @param {number=} opt_timeoutSeconds
+ */
+u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) {
+  if (js_api_version === undefined) {
+    // Send a message to get the extension to JS API version, then send the actual sign request.
+    u2f.getApiVersion(
+        function (response) {
+          js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version'];
+          console.log("Extension JS API Version: ", js_api_version);
+          u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds);
+        });
+  } else {
+    // We know the JS API version. Send the actual sign request in the supported API version.
+    u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds);
+  }
+};
+
+/**
+ * Dispatches an array of sign requests to available U2F tokens.
+ * @param {string=} appId
+ * @param {string=} challenge
+ * @param {Array<u2f.RegisteredKey>} registeredKeys
+ * @param {function((u2f.Error|u2f.SignResponse))} callback
+ * @param {number=} opt_timeoutSeconds
+ */
+u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) {
+  u2f.getPortSingleton_(function(port) {
+    var reqId = ++u2f.reqCounter_;
+    u2f.callbackMap_[reqId] = callback;
+    var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ?
+        opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC);
+    var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId);
+    port.postMessage(req);
+  });
+};
+
+/**
+ * Dispatches register requests to available U2F tokens. An array of sign
+ * requests identifies already registered tokens.
+ * If the JS API version supported by the extension is unknown, it first sends a
+ * message to the extension to find out the supported API version and then it sends
+ * the register request.
+ * @param {string=} appId
+ * @param {Array<u2f.RegisterRequest>} registerRequests
+ * @param {Array<u2f.RegisteredKey>} registeredKeys
+ * @param {function((u2f.Error|u2f.RegisterResponse))} callback
+ * @param {number=} opt_timeoutSeconds
+ */
+u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) {
+  if (js_api_version === undefined) {
+    // Send a message to get the extension to JS API version, then send the actual register request.
+    u2f.getApiVersion(
+        function (response) {
+          js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version'];
+          console.log("Extension JS API Version: ", js_api_version);
+          u2f.sendRegisterRequest(appId, registerRequests, registeredKeys,
+              callback, opt_timeoutSeconds);
+        });
+  } else {
+    // We know the JS API version. Send the actual register request in the supported API version.
+    u2f.sendRegisterRequest(appId, registerRequests, registeredKeys,
+        callback, opt_timeoutSeconds);
+  }
+};
+
+/**
+ * Dispatches register requests to available U2F tokens. An array of sign
+ * requests identifies already registered tokens.
+ * @param {string=} appId
+ * @param {Array<u2f.RegisterRequest>} registerRequests
+ * @param {Array<u2f.RegisteredKey>} registeredKeys
+ * @param {function((u2f.Error|u2f.RegisterResponse))} callback
+ * @param {number=} opt_timeoutSeconds
+ */
+u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) {
+  u2f.getPortSingleton_(function(port) {
+    var reqId = ++u2f.reqCounter_;
+    u2f.callbackMap_[reqId] = callback;
+    var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ?
+        opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC);
+    var req = u2f.formatRegisterRequest_(
+        appId, registeredKeys, registerRequests, timeoutSeconds, reqId);
+    port.postMessage(req);
+  });
+};
+
+
+/**
+ * Dispatches a message to the extension to find out the supported
+ * JS API version.
+ * If the user is on a mobile phone and is thus using Google Authenticator instead
+ * of the Chrome extension, don't send the request and simply return 0.
+ * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback
+ * @param {number=} opt_timeoutSeconds
+ */
+u2f.getApiVersion = function(callback, opt_timeoutSeconds) {
+ u2f.getPortSingleton_(function(port) {
+   // If we are using Android Google Authenticator or iOS client app,
+   // do not fire an intent to ask which JS API version to use.
+   if (port.getPortType) {
+     var apiVersion;
+     switch (port.getPortType()) {
+       case 'WrappedIosPort_':
+       case 'WrappedAuthenticatorPort_':
+         apiVersion = 1.1;
+         break;
+
+       default:
+         apiVersion = 0;
+         break;
+     }
+     callback({ 'js_api_version': apiVersion });
+     return;
+   }
+    var reqId = ++u2f.reqCounter_;
+    u2f.callbackMap_[reqId] = callback;
+    var req = {
+      type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST,
+      timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ?
+          opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC),
+      requestId: reqId
+    };
+    port.postMessage(req);
+  });
+};
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (27 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows Wolfgang Bumiller
                   ` (3 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

copied from pbs and added u2f tab

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Makefile            |   1 +
 src/window/TfaWindow.js | 429 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 430 insertions(+)
 create mode 100644 src/window/TfaWindow.js

diff --git a/src/Makefile b/src/Makefile
index fe915dd..afb0cb2 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -78,6 +78,7 @@ JSSRC=					\
 	window/FileBrowser.js		\
 	window/AuthEditBase.js		\
 	window/AuthEditOpenId.js	\
+	window/TfaWindow.js		\
 	node/APT.js			\
 	node/APTRepositories.js		\
 	node/NetworkEdit.js		\
diff --git a/src/window/TfaWindow.js b/src/window/TfaWindow.js
new file mode 100644
index 0000000..5026fb8
--- /dev/null
+++ b/src/window/TfaWindow.js
@@ -0,0 +1,429 @@
+/*global u2f*/
+Ext.define('Proxmox.window.TfaLoginWindow', {
+    extend: 'Ext.window.Window',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext("Second login factor required"),
+
+    modal: true,
+    resizable: false,
+    width: 512,
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    defaultButton: 'tfaButton',
+
+    viewModel: {
+	data: {
+	    confirmText: gettext('Confirm Second Factor'),
+	    canConfirm: false,
+	    availableChallenge: {},
+	},
+    },
+
+    cancelled: true,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    if (!view.userid) {
+		throw "no userid given";
+	    }
+	    if (!view.ticket) {
+		throw "no ticket given";
+	    }
+	    const challenge = view.challenge;
+	    if (!challenge) {
+		throw "no challenge given";
+	    }
+
+	    let lastTabId = me.getLastTabUsed();
+	    let initialTab = -1, i = 0;
+	    for (const k of ['webauthn', 'totp', 'recovery', 'u2f']) {
+		const available = !!challenge[k];
+		vm.set(`availableChallenge.${k}`, available);
+
+		if (available) {
+		    if (i === lastTabId) {
+			initialTab = i;
+		    } else if (initialTab < 0) {
+			initialTab = i;
+		    }
+		}
+		i++;
+	    }
+	    view.down('tabpanel').setActiveTab(initialTab);
+
+	    if (challenge.recovery) {
+		me.lookup('availableRecovery').update(Ext.String.htmlEncode(
+		    gettext('Available recovery keys: ') + view.challenge.recovery.join(', '),
+		));
+		me.lookup('availableRecovery').setVisible(true);
+		if (view.challenge.recovery.length <= 3) {
+		    me.lookup('recoveryLow').setVisible(true);
+		}
+	    }
+
+	    if (challenge.webauthn && initialTab === 0) {
+		let _promise = me.loginWebauthn();
+	    } else if (challenge.u2f && initialTab === 3) {
+		let _promise = me.loginU2F();
+	    }
+	},
+	control: {
+	    'tabpanel': {
+		tabchange: function(tabPanel, newCard, oldCard) {
+		    // for now every TFA method has at max one field, so keep it simple..
+		    let oldField = oldCard.down('field');
+		    if (oldField) {
+			oldField.setDisabled(true);
+		    }
+		    let newField = newCard.down('field');
+		    if (newField) {
+			newField.setDisabled(false);
+			newField.focus();
+			newField.validate();
+		    }
+
+		    let confirmText = newCard.confirmText || gettext('Confirm Second Factor');
+		    this.getViewModel().set('confirmText', confirmText);
+
+		    this.saveLastTabUsed(tabPanel, newCard);
+		},
+	    },
+	    'field': {
+		validitychange: function(field, valid) {
+		    // triggers only for enabled fields and we disable the one from the
+		    // non-visible tab, so we can just directly use the valid param
+		    this.getViewModel().set('canConfirm', valid);
+		},
+		afterrender: field => field.focus(), // ensure focus after initial render
+	    },
+	},
+
+	saveLastTabUsed: function(tabPanel, card) {
+	    let id = tabPanel.items.indexOf(card);
+	    window.localStorage.setItem('Proxmox.TFALogin.lastTab', JSON.stringify({ id }));
+	},
+
+	getLastTabUsed: function() {
+	    let data = window.localStorage.getItem('Proxmox.TFALogin.lastTab');
+	    if (typeof data === 'string') {
+		let last = JSON.parse(data);
+		return last.id;
+	    }
+	    return null;
+	},
+
+	onClose: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    if (!view.cancelled) {
+		return;
+	    }
+
+	    view.onReject();
+	},
+
+	cancel: function() {
+	    this.getView().close();
+	},
+
+	loginTotp: function() {
+	    let me = this;
+
+	    let code = me.lookup('totp').getValue();
+	    let _promise = me.finishChallenge(`totp:${code}`);
+	},
+
+	loginWebauthn: async function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    me.lookup('webAuthnWaiting').setVisible(true);
+	    me.lookup('webAuthnError').setVisible(false);
+
+	    let challenge = view.challenge.webauthn;
+
+	    if (typeof challenge.string !== 'string') {
+		// Byte array fixup, keep challenge string:
+		challenge.string = challenge.publicKey.challenge;
+		challenge.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge.string);
+		for (const cred of challenge.publicKey.allowCredentials) {
+		    cred.id = Proxmox.Utils.base64url_to_bytes(cred.id);
+		}
+	    }
+
+	    let controller = new AbortController();
+	    challenge.signal = controller.signal;
+
+	    let hwrsp;
+	    try {
+		//Promise.race( ...
+		hwrsp = await navigator.credentials.get(challenge);
+	    } catch (error) {
+		// we do NOT want to fail login because of canceling the challenge actively,
+		// in some browser that's the only way to switch over to another method as the
+		// disallow user input during the time the challenge is active
+		// checking for error.code === DOMException.ABORT_ERR only works in firefox -.-
+		this.getViewModel().set('canConfirm', true);
+		// FIXME: better handling, show some message, ...?
+		me.lookup('webAuthnError').setData({
+		    error: Ext.htmlEncode(error.toString()),
+		});
+		me.lookup('webAuthnError').setVisible(true);
+		return;
+	    } finally {
+		let waitingMessage = me.lookup('webAuthnWaiting');
+		if (waitingMessage) {
+		    waitingMessage.setVisible(false);
+		}
+	    }
+
+	    let response = {
+		id: hwrsp.id,
+		type: hwrsp.type,
+		challenge: challenge.string,
+		rawId: Proxmox.Utils.bytes_to_base64url(hwrsp.rawId),
+		response: {
+		    authenticatorData: Proxmox.Utils.bytes_to_base64url(
+			hwrsp.response.authenticatorData,
+		    ),
+		    clientDataJSON: Proxmox.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON),
+		    signature: Proxmox.Utils.bytes_to_base64url(hwrsp.response.signature),
+		},
+	    };
+
+	    await me.finishChallenge("webauthn:" + JSON.stringify(response));
+	},
+
+	loginU2F: async function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    me.lookup('u2fWaiting').setVisible(true);
+	    me.lookup('u2fError').setVisible(false);
+
+	    let hwrsp;
+	    try {
+		hwrsp = await new Promise((resolve, reject) => {
+		    try {
+			let data = view.challenge.u2f;
+			let chlg = data.challenge;
+			u2f.sign(chlg.appId, chlg.challenge, data.keys, resolve);
+		    } catch (error) {
+			reject(error);
+		    }
+		});
+		if (hwrsp.errorCode) {
+		    throw Proxmox.Utils.render_u2f_error(hwrsp.errorCode);
+		}
+		delete hwrsp.errorCode;
+	    } catch (error) {
+		this.getViewModel().set('canConfirm', true);
+		me.lookup('u2fError').setData({
+		    error: Ext.htmlEncode(error.toString()),
+		});
+		me.lookup('u2fError').setVisible(true);
+		return;
+	    } finally {
+		let waitingMessage = me.lookup('u2fWaiting');
+		if (waitingMessage) {
+		    waitingMessage.setVisible(false);
+		}
+	    }
+
+	    await me.finishChallenge("u2f:" + JSON.stringify(hwrsp));
+	},
+
+	loginRecovery: function() {
+	    let me = this;
+
+	    let key = me.lookup('recoveryKey').getValue();
+	    let _promise = me.finishChallenge(`recovery:${key}`);
+	},
+
+	loginTFA: function() {
+	    let me = this;
+	    // avoid triggering more than once during challenge
+	    me.getViewModel().set('canConfirm', false);
+	    let view = me.getView();
+	    let tfaPanel = view.down('tabpanel').getActiveTab();
+	    me[tfaPanel.handler]();
+	},
+
+	finishChallenge: function(password) {
+	    let me = this;
+	    let view = me.getView();
+	    view.cancelled = false;
+
+	    let params = {
+		username: view.userid,
+		'tfa-challenge': view.ticket,
+		password,
+	    };
+
+	    let resolve = view.onResolve;
+	    let reject = view.onReject;
+	    view.close();
+
+	    return Proxmox.Async.api2({
+		url: '/api2/extjs/access/ticket',
+		method: 'POST',
+		params,
+	    })
+	    .then(resolve)
+	    .catch(reject);
+	},
+    },
+
+    listeners: {
+	close: 'onClose',
+    },
+
+    items: [{
+	xtype: 'tabpanel',
+	region: 'center',
+	layout: 'fit',
+	bodyPadding: 10,
+	items: [
+	    {
+		xtype: 'panel',
+		title: 'WebAuthn',
+		iconCls: 'fa fa-fw fa-shield',
+		confirmText: gettext('Start WebAuthn challenge'),
+		handler: 'loginWebauthn',
+		bind: {
+		    disabled: '{!availableChallenge.webauthn}',
+		},
+		items: [
+		    {
+			xtype: 'box',
+			html: gettext('Please insert your authentication device and press its button'),
+		    },
+		    {
+			xtype: 'box',
+			html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
+			reference: 'webAuthnWaiting',
+			hidden: true,
+		    },
+		    {
+			xtype: 'box',
+			data: {
+			    error: '',
+			},
+			tpl: '<i class="fa fa-warning warning"></i> {error}',
+			reference: 'webAuthnError',
+			hidden: true,
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: gettext('TOTP App'),
+		iconCls: 'fa fa-fw fa-clock-o',
+		handler: 'loginTotp',
+		bind: {
+		    disabled: '{!availableChallenge.totp}',
+		},
+		items: [
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Please enter your TOTP verification code'),
+			labelWidth: 300,
+			name: 'totp',
+			disabled: true,
+			reference: 'totp',
+			allowBlank: false,
+			regex: /^[0-9]{2,16}$/,
+			regexText: gettext('TOTP codes usually consist of six decimal digits'),
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: gettext('Recovery Key'),
+		iconCls: 'fa fa-fw fa-file-text-o',
+		handler: 'loginRecovery',
+		bind: {
+		    disabled: '{!availableChallenge.recovery}',
+		},
+		items: [
+		    {
+			xtype: 'box',
+			reference: 'availableRecovery',
+			hidden: true,
+		    },
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Please enter one of your single-use recovery keys'),
+			labelWidth: 300,
+			name: 'recoveryKey',
+			disabled: true,
+			reference: 'recoveryKey',
+			allowBlank: false,
+			regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/,
+			regexText: gettext('Does not look like a valid recovery key'),
+		    },
+		    {
+			xtype: 'box',
+			reference: 'recoveryLow',
+			hidden: true,
+			html: '<i class="fa fa-exclamation-triangle warning"></i>'
+			    + gettext('Less than {0} recovery keys available. Please generate a new set after login!'),
+		    },
+		],
+	    },
+	    {
+		xtype: 'panel',
+		title: 'U2F',
+		iconCls: 'fa fa-fw fa-shield',
+		confirmText: gettext('Start U2F challenge'),
+		handler: 'loginU2F',
+		bind: {
+		    disabled: '{!availableChallenge.u2f}',
+		},
+		items: [
+		    {
+			xtype: 'box',
+			html: gettext('Please insert your authentication device and press its button'),
+		    },
+		    {
+			xtype: 'box',
+			html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
+			reference: 'u2fWaiting',
+			hidden: true,
+		    },
+		    {
+			xtype: 'box',
+			data: {
+			    error: '',
+			},
+			tpl: '<i class="fa fa-warning warning"></i> {error}',
+			reference: 'u2fError',
+			hidden: true,
+		    },
+		],
+	    },
+	],
+    }],
+
+    buttons: [
+	{
+	    handler: 'loginTFA',
+	    reference: 'tfaButton',
+	    disabled: true,
+	    bind: {
+		text: '{confirmText}',
+		disabled: '{!canConfirm}',
+	    },
+	},
+    ],
+});
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (28 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView Wolfgang Bumiller
                   ` (2 subsequent siblings)
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

plain copy from pbs with s/pbs/pmx/ and s/PBS/Proxmox/

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Makefile                 |   4 +
 src/window/AddTfaRecovery.js | 224 ++++++++++++++++++++++++++
 src/window/AddTotp.js        | 297 +++++++++++++++++++++++++++++++++++
 src/window/AddWebauthn.js    | 226 ++++++++++++++++++++++++++
 src/window/TfaEdit.js        |  93 +++++++++++
 5 files changed, 844 insertions(+)
 create mode 100644 src/window/AddTfaRecovery.js
 create mode 100644 src/window/AddTotp.js
 create mode 100644 src/window/AddWebauthn.js
 create mode 100644 src/window/TfaEdit.js

diff --git a/src/Makefile b/src/Makefile
index afb0cb2..ad7a3c2 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -79,6 +79,10 @@ JSSRC=					\
 	window/AuthEditBase.js		\
 	window/AuthEditOpenId.js	\
 	window/TfaWindow.js		\
+	window/AddTfaRecovery.js	\
+	window/AddTotp.js		\
+	window/AddWebauthn.js		\
+	window/TfaEdit.js		\
 	node/APT.js			\
 	node/APTRepositories.js		\
 	node/NetworkEdit.js		\
diff --git a/src/window/AddTfaRecovery.js b/src/window/AddTfaRecovery.js
new file mode 100644
index 0000000..174d553
--- /dev/null
+++ b/src/window/AddTfaRecovery.js
@@ -0,0 +1,224 @@
+Ext.define('Proxmox.window.AddTfaRecovery', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAddTfaRecovery',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+    isCreate: true,
+    isAdd: true,
+    subject: gettext('TFA recovery keys'),
+    width: 512,
+    method: 'POST',
+
+    fixedUser: false,
+
+    url: '/api2/extjs/access/tfa',
+    submitUrl: function(url, values) {
+	let userid = values.userid;
+	delete values.userid;
+	return `${url}/${userid}`;
+    },
+
+    apiCallDone: function(success, response) {
+	if (!success) {
+	    return;
+	}
+
+	let values = response
+	    .result
+	    .data
+	    .recovery
+	    .map((v, i) => `${i}: ${v}`)
+	    .join("\n");
+	Ext.create('Proxmox.window.TfaRecoveryShow', {
+	    autoShow: true,
+	    userid: this.getViewModel().get('userid'),
+	    values,
+	});
+    },
+
+    viewModel: {
+	data: {
+	    has_entry: false,
+	    userid: null,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	hasEntry: async function(userid) {
+	    let me = this;
+	    let view = me.getView();
+
+	    try {
+		await Proxmox.Async.api2({
+		    url: `${view.url}/${userid}/recovery`,
+		    method: 'GET',
+		});
+		return true;
+	    } catch (_response) {
+		return false;
+	    }
+	},
+
+	init: function(view) {
+	    this.onUseridChange(null, Proxmox.UserName);
+	},
+
+	onUseridChange: async function(field, userid) {
+	    let me = this;
+	    let vm = me.getViewModel();
+
+	    me.userid = userid;
+	    vm.set('userid', userid);
+
+	    let has_entry = await me.hasEntry(userid);
+	    vm.set('has_entry', has_entry);
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'userid',
+	    cbind: {
+		editable: (get) => !get('fixedUser'),
+		value: () => Proxmox.UserName,
+	    },
+	    fieldLabel: gettext('User'),
+	    editConfig: {
+		xtype: 'pmxUserSelector',
+		allowBlank: false,
+		validator: function(_value) {
+		    return !this.up('window').getViewModel().get('has_entry');
+		},
+	    },
+	    renderer: Ext.String.htmlEncode,
+	    listeners: {
+		change: 'onUseridChange',
+	    },
+	},
+	{
+	    xtype: 'hiddenfield',
+	    name: 'type',
+	    value: 'recovery',
+	},
+	{
+	    xtype: 'displayfield',
+	    bind: {
+		hidden: '{!has_entry}',
+	    },
+	    hidden: true,
+	    userCls: 'pmx-hint',
+	    value: gettext('User already has recovery keys.'),
+	},
+	{
+	    xtype: 'textfield',
+	    name: 'password',
+	    reference: 'password',
+	    fieldLabel: gettext('Verify Password'),
+	    inputType: 'password',
+	    minLength: 5,
+	    allowBlank: false,
+	    validateBlank: true,
+	    cbind: {
+		hidden: () => Proxmox.UserName === 'root@pam',
+		disabled: () => Proxmox.UserName === 'root@pam',
+		emptyText: () =>
+		    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+	    },
+	},
+    ],
+});
+
+Ext.define('Proxmox.window.TfaRecoveryShow', {
+    extend: 'Ext.window.Window',
+    alias: ['widget.pmxTfaRecoveryShow'],
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    width: 600,
+    modal: true,
+    resizable: false,
+    title: gettext('Recovery Keys'),
+    onEsc: Ext.emptyFn,
+
+    items: [
+	{
+	    xtype: 'form',
+	    layout: 'anchor',
+	    bodyPadding: 10,
+	    border: false,
+	    fieldDefaults: {
+		anchor: '100%',
+            },
+	    items: [
+		{
+		    xtype: 'textarea',
+		    editable: false,
+		    inputId: 'token-secret-value',
+		    cbind: {
+			value: '{values}',
+		    },
+		    fieldStyle: {
+			'fontFamily': 'monospace',
+		    },
+		    height: '160px',
+		},
+		{
+		    xtype: 'displayfield',
+		    border: false,
+		    padding: '5 0 0 0',
+		    userCls: 'pmx-hint',
+		    value: gettext('Please record recovery keys - they will only be displayed now'),
+		},
+	    ],
+	},
+    ],
+    buttons: [
+	{
+	    handler: function(b) {
+		document.getElementById('token-secret-value').select();
+		document.execCommand("copy");
+	    },
+	    iconCls: 'fa fa-clipboard',
+	    text: gettext('Copy Recovery Keys'),
+	},
+	{
+	    handler: function(b) {
+		let win = this.up('window');
+		win.paperkeys(win.values, win.userid);
+	    },
+	    iconCls: 'fa fa-print',
+	    text: gettext('Print Recovery Keys'),
+	},
+    ],
+    paperkeys: function(keyString, userid) {
+	let me = this;
+
+	let printFrame = document.createElement("iframe");
+	Object.assign(printFrame.style, {
+	    position: "fixed",
+	    right: "0",
+	    bottom: "0",
+	    width: "0",
+	    height: "0",
+	    border: "0",
+	});
+	const host = document.location.host;
+	const title = document.title;
+	const html = `<html><head><script>
+	    window.addEventListener('DOMContentLoaded', (ev) => window.print());
+	</script><style>@media print and (max-height: 150mm) {
+	  h4, p { margin: 0; font-size: 1em; }
+	}</style></head><body style="padding: 5px;">
+	<h4>Recovery Keys for '${userid}' - ${title} (${host})</h4>
+<p style="font-size:1.5em;line-height:1.5em;font-family:monospace;
+   white-space:pre-wrap;overflow-wrap:break-word;">
+${keyString}
+</p>
+	</body></html>`;
+
+	printFrame.src = "data:text/html;base64," + btoa(html);
+	document.body.appendChild(printFrame);
+    },
+});
diff --git a/src/window/AddTotp.js b/src/window/AddTotp.js
new file mode 100644
index 0000000..3e0f5b5
--- /dev/null
+++ b/src/window/AddTotp.js
@@ -0,0 +1,297 @@
+/*global QRCode*/
+Ext.define('Proxmox.window.AddTotp', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAddTotp',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext('Add a TOTP login factor'),
+    width: 512,
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    isAdd: true,
+    userid: undefined,
+    tfa_id: undefined,
+    issuerName: 'Proxmox',
+    fixedUser: false,
+
+    updateQrCode: function() {
+	let me = this;
+	let values = me.lookup('totp_form').getValues();
+	let algorithm = values.algorithm;
+	if (!algorithm) {
+	    algorithm = 'SHA1';
+	}
+
+	let otpuri =
+	    'otpauth://totp/' +
+	    encodeURIComponent(values.issuer) +
+	    ':' +
+	    encodeURIComponent(values.userid) +
+	    '?secret=' + values.secret +
+	    '&period=' + values.step +
+	    '&digits=' + values.digits +
+	    '&algorithm=' + algorithm +
+	    '&issuer=' + encodeURIComponent(values.issuer);
+
+	me.getController().getViewModel().set('otpuri', otpuri);
+	me.qrcode.makeCode(otpuri);
+	me.lookup('challenge').setVisible(true);
+	me.down('#qrbox').setVisible(true);
+    },
+
+    viewModel: {
+	data: {
+	    valid: false,
+	    secret: '',
+	    otpuri: '',
+	    userid: null,
+	},
+
+	formulas: {
+	    secretEmpty: function(get) {
+		return get('secret').length === 0;
+	    },
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+	control: {
+	    'field[qrupdate=true]': {
+		change: function() {
+		    this.getView().updateQrCode();
+		},
+	    },
+	    'field': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    let viewModel = me.getViewModel();
+		    let form = me.lookup('totp_form');
+		    let challenge = me.lookup('challenge');
+		    let password = me.lookup('password');
+		    viewModel.set('valid', form.isValid() && challenge.isValid() && password.isValid());
+		},
+	    },
+	    '#': {
+		show: function() {
+		    let me = this;
+		    let view = me.getView();
+
+		    view.qrdiv = document.createElement('div');
+		    view.qrcode = new QRCode(view.qrdiv, {
+			width: 256,
+			height: 256,
+			correctLevel: QRCode.CorrectLevel.M,
+		    });
+		    view.down('#qrbox').getEl().appendChild(view.qrdiv);
+
+		    view.getController().randomizeSecret();
+		},
+	    },
+	},
+
+	randomizeSecret: function() {
+	    let me = this;
+	    let rnd = new Uint8Array(32);
+	    window.crypto.getRandomValues(rnd);
+	    let data = '';
+	    rnd.forEach(function(b) {
+		// secret must be base32, so just use the first 5 bits
+		b = b & 0x1f;
+		if (b < 26) {
+		    // A..Z
+		    data += String.fromCharCode(b + 0x41);
+		} else {
+		    // 2..7
+		    data += String.fromCharCode(b-26 + 0x32);
+		}
+	    });
+	    me.getViewModel().set('secret', data);
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    layout: 'anchor',
+	    border: false,
+	    reference: 'totp_form',
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'userid',
+		    cbind: {
+			editable: (get) => get('isAdd') && !get('fixedUser'),
+			value: () => Proxmox.UserName,
+		    },
+		    fieldLabel: gettext('User'),
+		    editConfig: {
+			xtype: 'pmxUserSelector',
+			allowBlank: false,
+		    },
+		    renderer: Ext.String.htmlEncode,
+		    listeners: {
+			change: function(field, newValue, oldValue) {
+			    let vm = this.up('window').getViewModel();
+			    vm.set('userid', newValue);
+			},
+		    },
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Description'),
+		    emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+		    allowBlank: false,
+		    name: 'description',
+		    maxLength: 256,
+		},
+		{
+		    layout: 'hbox',
+		    border: false,
+		    padding: '0 0 5 0',
+		    items: [
+			{
+			    xtype: 'textfield',
+			    fieldLabel: gettext('Secret'),
+			    emptyText: gettext('Unchanged'),
+			    name: 'secret',
+			    reference: 'tfa_secret',
+			    regex: /^[A-Z2-7=]+$/,
+			    regexText: 'Must be base32 [A-Z2-7=]',
+			    maskRe: /[A-Z2-7=]/,
+			    qrupdate: true,
+			    bind: {
+				value: "{secret}",
+			    },
+			    flex: 4,
+			    padding: '0 5 0 0',
+			},
+			{
+			    xtype: 'button',
+			    text: gettext('Randomize'),
+			    reference: 'randomize_button',
+			    handler: 'randomizeSecret',
+			    flex: 1,
+			},
+		    ],
+		},
+		{
+		    xtype: 'numberfield',
+		    fieldLabel: gettext('Time period'),
+		    name: 'step',
+		    // Google Authenticator ignores this and generates bogus data
+		    hidden: true,
+		    value: 30,
+		    minValue: 10,
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'numberfield',
+		    fieldLabel: gettext('Digits'),
+		    name: 'digits',
+		    value: 6,
+		    // Google Authenticator ignores this and generates bogus data
+		    hidden: true,
+		    minValue: 6,
+		    maxValue: 8,
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Issuer Name'),
+		    name: 'issuer',
+		    cbind: {
+			value: '{issuerName}',
+		    },
+		    qrupdate: true,
+		},
+		{
+		    xtype: 'box',
+		    itemId: 'qrbox',
+		    visible: false, // will be enabled when generating a qr code
+		    bind: {
+			visible: '{!secretEmpty}',
+		    },
+		    style: {
+			'background-color': 'white',
+			'margin-left': 'auto',
+			'margin-right': 'auto',
+			padding: '5px',
+			width: '266px',
+			height: '266px',
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Verify Code'),
+		    allowBlank: false,
+		    reference: 'challenge',
+		    name: 'challenge',
+		    bind: {
+			disabled: '{!showTOTPVerifiction}',
+			visible: '{showTOTPVerifiction}',
+		    },
+		    emptyText: gettext('Scan QR code in a TOTP app and enter an auth. code here'),
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'password',
+		    reference: 'password',
+		    fieldLabel: gettext('Verify Password'),
+		    inputType: 'password',
+		    minLength: 5,
+		    allowBlank: false,
+		    validateBlank: true,
+		    cbind: {
+			hidden: () => Proxmox.UserName === 'root@pam',
+			disabled: () => Proxmox.UserName === 'root@pam',
+			emptyText: () =>
+			    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+		    },
+		},
+	    ],
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+	me.url = '/api2/extjs/access/tfa/';
+	me.method = 'POST';
+	me.callParent();
+    },
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+	let viewmodel = me.getController().getViewModel();
+
+	let values = me.callParent(arguments);
+
+	let uid = encodeURIComponent(values.userid);
+	me.url = `/api2/extjs/access/tfa/${uid}`;
+	delete values.userid;
+
+	let data = {
+	    description: values.description,
+	    type: "totp",
+	    totp: viewmodel.get('otpuri'),
+	    value: values.challenge,
+	};
+
+	if (values.password) {
+	    data.password = values.password;
+	}
+
+	return data;
+    },
+});
diff --git a/src/window/AddWebauthn.js b/src/window/AddWebauthn.js
new file mode 100644
index 0000000..f4a0b10
--- /dev/null
+++ b/src/window/AddWebauthn.js
@@ -0,0 +1,226 @@
+Ext.define('Proxmox.window.AddWebauthn', {
+    extend: 'Ext.window.Window',
+    alias: 'widget.pmxAddWebauthn',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext('Add a Webauthn login token'),
+    width: 512,
+
+    user: undefined,
+    fixedUser: false,
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	Ext.GlobalEvents.fireEvent('proxmoxShowHelp', me.onlineHelp);
+    },
+
+    viewModel: {
+	data: {
+	    valid: false,
+	    userid: null,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'field': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    let viewmodel = me.getViewModel();
+		    let form = me.lookup('webauthn_form');
+		    viewmodel.set('valid', form.isValid());
+		},
+	    },
+	    '#': {
+		show: function() {
+		    let me = this;
+		    let view = me.getView();
+
+		    if (Proxmox.UserName === 'root@pam') {
+			view.lookup('password').setVisible(false);
+			view.lookup('password').setDisabled(true);
+		    }
+		},
+	    },
+	},
+
+	registerWebauthn: async function() {
+	    let me = this;
+	    let values = me.lookup('webauthn_form').getValues();
+	    values.type = "webauthn";
+
+	    let userid = values.user;
+	    delete values.user;
+
+	    me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
+
+	    try {
+		let register_response = await Proxmox.Async.api2({
+		    url: `/api2/extjs/access/tfa/${userid}`,
+		    method: 'POST',
+		    params: values,
+		});
+
+		let data = register_response.result.data;
+		if (!data.challenge) {
+		    throw "server did not respond with a challenge";
+		}
+
+		let creds = JSON.parse(data.challenge);
+
+		// Fix this up before passing it to the browser, but keep a copy of the original
+		// string to pass in the response:
+		let challenge_str = creds.publicKey.challenge;
+		creds.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge_str);
+		creds.publicKey.user.id =
+		    Proxmox.Utils.base64url_to_bytes(creds.publicKey.user.id);
+
+		// convert existing authenticators structure
+		creds.publicKey.excludeCredentials =
+		    (creds.publicKey.excludeCredentials || [])
+		    .map((credential) => ({
+			id: Proxmox.Utils.base64url_to_bytes(credential.id),
+			type: credential.type,
+		    }));
+
+		let msg = Ext.Msg.show({
+		    title: `Webauthn: ${gettext('Setup')}`,
+		    message: gettext('Please press the button on your Webauthn Device'),
+		    buttons: [],
+		});
+
+		let token_response;
+		try {
+		    token_response = await navigator.credentials.create(creds);
+		} catch (error) {
+		    let errmsg = error.message;
+		    if (error.name === 'InvalidStateError') {
+			errmsg = gettext('Is this token already registered?');
+		    }
+		    throw gettext('An error occurred during token registration.') +
+			`<br>${error.name}: ${errmsg}`;
+		}
+
+		// We cannot pass ArrayBuffers to the API, so extract & convert the data.
+		let response = {
+		    id: token_response.id,
+		    type: token_response.type,
+		    rawId: Proxmox.Utils.bytes_to_base64url(token_response.rawId),
+		    response: {
+			attestationObject: Proxmox.Utils.bytes_to_base64url(
+			    token_response.response.attestationObject,
+			),
+			clientDataJSON: Proxmox.Utils.bytes_to_base64url(
+			    token_response.response.clientDataJSON,
+			),
+		    },
+		};
+
+		msg.close();
+
+		let params = {
+		    type: "webauthn",
+		    challenge: challenge_str,
+		    value: JSON.stringify(response),
+		};
+
+		if (values.password) {
+		    params.password = values.password;
+		}
+
+		await Proxmox.Async.api2({
+		    url: `/api2/extjs/access/tfa/${userid}`,
+		    method: 'POST',
+		    params,
+		});
+	    } catch (response) {
+		let error = response.result.message;
+		console.error(error); // for debugging if it's not displayable...
+		Ext.Msg.alert(gettext('Error'), error);
+	    }
+
+	    me.getView().close();
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'webauthn_form',
+	    layout: 'anchor',
+	    border: false,
+	    bodyPadding: 10,
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'user',
+		    cbind: {
+			editable: (get) => !get('fixedUser'),
+			value: () => Proxmox.UserName,
+		    },
+		    fieldLabel: gettext('User'),
+		    editConfig: {
+			xtype: 'pmxUserSelector',
+			allowBlank: false,
+		    },
+		    renderer: Ext.String.htmlEncode,
+		    listeners: {
+			change: function(field, newValue, oldValue) {
+			    let vm = this.up('window').getViewModel();
+			    vm.set('userid', newValue);
+			},
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Description'),
+		    allowBlank: false,
+		    name: 'description',
+		    maxLength: 256,
+		    emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'password',
+		    reference: 'password',
+		    fieldLabel: gettext('Verify Password'),
+		    inputType: 'password',
+		    minLength: 5,
+		    allowBlank: false,
+		    validateBlank: true,
+		    cbind: {
+			hidden: () => Proxmox.UserName === 'root@pam',
+			disabled: () => Proxmox.UserName === 'root@pam',
+			emptyText: () =>
+			    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+		    },
+		},
+	    ],
+	},
+    ],
+
+    buttons: [
+	{
+	    xtype: 'proxmoxHelpButton',
+	},
+	'->',
+	{
+	    xtype: 'button',
+	    text: gettext('Register Webauthn Device'),
+	    handler: 'registerWebauthn',
+	    bind: {
+		disabled: '{!valid}',
+	    },
+	},
+    ],
+});
diff --git a/src/window/TfaEdit.js b/src/window/TfaEdit.js
new file mode 100644
index 0000000..710f2b9
--- /dev/null
+++ b/src/window/TfaEdit.js
@@ -0,0 +1,93 @@
+Ext.define('Proxmox.window.TfaEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxTfaEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext("Modify a TFA entry's description"),
+    width: 512,
+
+    layout: {
+	type: 'vbox',
+	align: 'stretch',
+    },
+
+    cbindData: function(initialConfig) {
+	let me = this;
+
+	let tfa_id = initialConfig['tfa-id'];
+	me.tfa_id = tfa_id;
+	me.defaultFocus = 'textfield[name=description]';
+	me.url = `/api2/extjs/access/tfa/${tfa_id}`;
+	me.method = 'PUT';
+	me.autoLoad = true;
+	return {};
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+
+	if (Proxmox.UserName === 'root@pam') {
+	    me.lookup('password').setVisible(false);
+	    me.lookup('password').setDisabled(true);
+	}
+
+	let userid = me.tfa_id.split('/')[0];
+	me.lookup('userid').setValue(userid);
+    },
+
+    items: [
+	{
+	    xtype: 'displayfield',
+	    reference: 'userid',
+	    editable: false,
+	    fieldLabel: gettext('User'),
+	    editConfig: {
+		xtype: 'pmxUserSelector',
+		allowBlank: false,
+	    },
+	    cbind: {
+		value: () => Proxmox.UserName,
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'description',
+	    allowBlank: false,
+	    fieldLabel: gettext('Description'),
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Enabled'),
+	    name: 'enable',
+	    uncheckedValue: 0,
+	    defaultValue: 1,
+	    checked: true,
+	},
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Password'),
+	    minLength: 5,
+	    reference: 'password',
+	    name: 'password',
+	    allowBlank: false,
+	    validateBlank: true,
+	    emptyText: gettext('verify current password'),
+	},
+    ],
+
+    getValues: function() {
+	var me = this;
+
+	var values = me.callParent(arguments);
+
+	delete values.userid;
+
+	return values;
+    },
+});
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (29 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 7/7] add yubico otp windows & login support Wolfgang Bumiller
  2021-11-11 15:52 ` [pve-devel] applied-series: [PATCH multiple 0/9] PBS-like TFA support in PVE Thomas Lamprecht
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

copied from pbs with s/pbs/pmx/ and s/PBS/Proxmox/

DELETE call changed from using a body to url parameters,
since pve doesn't support a body there currently, and pbs
doesn't care

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Makefile          |   1 +
 src/panel/TfaView.js  | 270 ++++++++++++++++++++++++++++++++++++++++++
 src/window/TfaEdit.js | 135 +++++++++++++++++++++
 3 files changed, 406 insertions(+)
 create mode 100644 src/panel/TfaView.js

diff --git a/src/Makefile b/src/Makefile
index ad7a3c2..bf2eab0 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -63,6 +63,7 @@ JSSRC=					\
 	panel/ACMEPlugin.js		\
 	panel/ACMEDomains.js		\
 	panel/StatusView.js		\
+	panel/TfaView.js		\
 	window/Edit.js			\
 	window/PasswordEdit.js		\
 	window/SafeDestroy.js		\
diff --git a/src/panel/TfaView.js b/src/panel/TfaView.js
new file mode 100644
index 0000000..a0cb04a
--- /dev/null
+++ b/src/panel/TfaView.js
@@ -0,0 +1,270 @@
+Ext.define('pmx-tfa-users', {
+    extend: 'Ext.data.Model',
+    fields: ['userid'],
+    idProperty: 'userid',
+    proxy: {
+	type: 'proxmox',
+	url: '/api2/json/access/tfa',
+    },
+});
+
+Ext.define('pmx-tfa-entry', {
+    extend: 'Ext.data.Model',
+    fields: ['fullid', 'userid', 'type', 'description', 'created', 'enable'],
+    idProperty: 'fullid',
+});
+
+
+Ext.define('Proxmox.panel.TfaView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pmxTfaView',
+
+    title: gettext('Second Factors'),
+    reference: 'tfaview',
+
+    issuerName: 'Proxmox',
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	model: 'pmx-tfa-entry',
+	rstore: {
+	    type: 'store',
+	    proxy: 'memory',
+	    storeid: 'pmx-tfa-entry',
+	    model: 'pmx-tfa-entry',
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	init: function(view) {
+	    let me = this;
+	    view.tfaStore = Ext.create('Proxmox.data.UpdateStore', {
+		autoStart: true,
+		interval: 5 * 1000,
+		storeid: 'pmx-tfa-users',
+		model: 'pmx-tfa-users',
+	    });
+	    view.tfaStore.on('load', this.onLoad, this);
+	    view.on('destroy', view.tfaStore.stopUpdate);
+	    Proxmox.Utils.monStoreErrors(view, view.tfaStore);
+	},
+
+	reload: function() { this.getView().tfaStore.load(); },
+
+	onLoad: function(store, data, success) {
+	    if (!success) return;
+
+	    let records = [];
+	    Ext.Array.each(data, user => {
+		Ext.Array.each(user.data.entries, entry => {
+		    records.push({
+			fullid: `${user.id}/${entry.id}`,
+			userid: user.id,
+			type: entry.type,
+			description: entry.description,
+			created: entry.created,
+			enable: entry.enable,
+		    });
+		});
+	    });
+
+	    let rstore = this.getView().store.rstore;
+	    rstore.loadData(records);
+	    rstore.fireEvent('load', rstore, records, true);
+	},
+
+	addTotp: function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddTotp', {
+		isCreate: true,
+		issuerName: me.getView().issuerName,
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+	    }).show();
+	},
+
+	addWebauthn: function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddWebauthn', {
+		isCreate: true,
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+	    }).show();
+	},
+
+	addRecovery: async function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddTfaRecovery', {
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+	    }).show();
+	},
+
+	editItem: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length !== 1 || selection[0].id.endsWith("/recovery")) {
+		return;
+	    }
+
+	    Ext.create('Proxmox.window.TfaEdit', {
+		'tfa-id': selection[0].data.fullid,
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+	    }).show();
+	},
+
+	renderUser: fullid => fullid.split('/')[0],
+
+	renderEnabled: enabled => {
+	    if (enabled === undefined) {
+		return Proxmox.Utils.yesText;
+	    } else {
+		return Proxmox.Utils.format_boolean(enabled);
+	    }
+	},
+
+	onRemoveButton: function(btn, event, record) {
+	    let me = this;
+
+	    Ext.create('Proxmox.tfa.confirmRemove', {
+		...record.data,
+		callback: password => me.removeItem(password, record),
+	    })
+	    .show();
+	},
+
+	removeItem: async function(password, record) {
+	    let me = this;
+
+	    if (password !== null) {
+		password = '?password=' + encodeURIComponent(password);
+	    } else {
+		password = '';
+	    }
+
+	    try {
+		me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
+		await Proxmox.Async.api2({
+		    url: `/api2/extjs/access/tfa/${record.id}${password}`,
+		    method: 'DELETE',
+		});
+		me.reload();
+	    } catch (response) {
+		Ext.Msg.alert(gettext('Error'), response.result.message);
+	    } finally {
+		me.getView().unmask();
+            }
+	},
+    },
+
+    viewConfig: {
+	trackOver: false,
+    },
+
+    listeners: {
+	itemdblclick: 'editItem',
+    },
+
+    columns: [
+	{
+	    header: gettext('User'),
+	    width: 200,
+	    sortable: true,
+	    dataIndex: 'fullid',
+	    renderer: 'renderUser',
+	},
+	{
+	    header: gettext('Enabled'),
+	    width: 80,
+	    sortable: true,
+	    dataIndex: 'enable',
+	    renderer: 'renderEnabled',
+	},
+	{
+	    header: gettext('TFA Type'),
+	    width: 80,
+	    sortable: true,
+	    dataIndex: 'type',
+	},
+	{
+	    header: gettext('Created'),
+	    width: 150,
+	    sortable: true,
+	    dataIndex: 'created',
+	    renderer: Proxmox.Utils.render_timestamp,
+	},
+	{
+	    header: gettext('Description'),
+	    width: 300,
+	    sortable: true,
+	    dataIndex: 'description',
+	    renderer: Ext.String.htmlEncode,
+	    flex: 1,
+	},
+    ],
+
+    tbar: [
+	{
+	    text: gettext('Add'),
+	    menu: {
+		xtype: 'menu',
+		items: [
+		    {
+			text: gettext('TOTP'),
+			itemId: 'totp',
+			iconCls: 'fa fa-fw fa-clock-o',
+			handler: 'addTotp',
+		    },
+		    {
+			text: gettext('Webauthn'),
+			itemId: 'webauthn',
+			iconCls: 'fa fa-fw fa-shield',
+			handler: 'addWebauthn',
+		    },
+		    {
+			text: gettext('Recovery Keys'),
+			itemId: 'recovery',
+			iconCls: 'fa fa-fw fa-file-text-o',
+			handler: 'addRecovery',
+		    },
+		],
+	    },
+	},
+	'-',
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    handler: 'editItem',
+	    enableFn: rec => !rec.id.endsWith("/recovery"),
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    disabled: true,
+	    text: gettext('Remove'),
+	    getRecordName: rec => rec.data.description,
+	    handler: 'onRemoveButton',
+	},
+    ],
+});
diff --git a/src/window/TfaEdit.js b/src/window/TfaEdit.js
index 710f2b9..4a8b937 100644
--- a/src/window/TfaEdit.js
+++ b/src/window/TfaEdit.js
@@ -91,3 +91,138 @@ Ext.define('Proxmox.window.TfaEdit', {
 	return values;
     },
 });
+
+Ext.define('Proxmox.tfa.confirmRemove', {
+    extend: 'Proxmox.window.Edit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    title: gettext("Confirm TFA Removal"),
+
+    modal: true,
+    resizable: false,
+    width: 600,
+    isCreate: true, // logic
+    isRemove: true,
+
+    url: '/access/tfa',
+
+    initComponent: function() {
+	let me = this;
+
+	if (typeof me.type !== "string") {
+	    throw "missing type";
+	}
+
+	if (!me.callback) {
+	    throw "missing callback";
+	}
+
+	me.callParent();
+
+	if (Proxmox.UserName === 'root@pam') {
+	    me.lookup('password').setVisible(false);
+	    me.lookup('password').setDisabled(true);
+	}
+    },
+
+    submit: function() {
+	let me = this;
+	if (Proxmox.UserName === 'root@pam') {
+	    me.callback(null);
+	} else {
+	    me.callback(me.lookup('password').getValue());
+	}
+	me.close();
+    },
+
+    items: [
+	{
+	    xtype: 'box',
+	    padding: '0 0 10 0',
+	    html: Ext.String.format(
+	        gettext('Are you sure you want to remove this {0} entry?'),
+	        'TFA',
+	    ),
+	},
+	{
+	    xtype: 'container',
+	    layout: {
+		type: 'hbox',
+		align: 'begin',
+	    },
+	    defaults: {
+		border: false,
+		layout: 'anchor',
+		flex: 1,
+		padding: 5,
+	    },
+	    items: [
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+		    },
+		    padding: '0 10 0 0',
+		    items: [
+			{
+			    xtype: 'displayfield',
+			    fieldLabel: gettext('User'),
+			    cbind: {
+				value: '{userid}',
+			    },
+			},
+			{
+			    xtype: 'displayfield',
+			    fieldLabel: gettext('Type'),
+			    cbind: {
+				value: '{type}',
+			    },
+			},
+		    ],
+		},
+		{
+		    xtype: 'container',
+		    layout: {
+			type: 'vbox',
+		    },
+		    padding: '0 0 0 10',
+		    items: [
+			{
+			    xtype: 'displayfield',
+			    fieldLabel: gettext('Created'),
+			    renderer: v => Proxmox.Utils.render_timestamp(v),
+			    cbind: {
+				value: '{created}',
+			    },
+			},
+			{
+			    xtype: 'textfield',
+			    fieldLabel: gettext('Description'),
+			    cbind: {
+				value: '{description}',
+			    },
+			    emptyText: Proxmox.Utils.NoneText,
+			    submitValue: false,
+			    editable: false,
+			},
+		    ],
+		},
+	    ],
+	},
+	{
+	    xtype: 'textfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Password'),
+	    minLength: 5,
+	    reference: 'password',
+	    name: 'password',
+	    allowBlank: false,
+	    validateBlank: true,
+	    padding: '10 0 0 0',
+	    cbind: {
+		emptyText: () =>
+		    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+	    },
+	},
+    ],
+});
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] [PATCH widget-toolkit 7/7] add yubico otp windows & login support
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (30 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView Wolfgang Bumiller
@ 2021-11-09 11:27 ` Wolfgang Bumiller
  2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
  2021-11-11 15:52 ` [pve-devel] applied-series: [PATCH multiple 0/9] PBS-like TFA support in PVE Thomas Lamprecht
  32 siblings, 1 reply; 43+ messages in thread
From: Wolfgang Bumiller @ 2021-11-09 11:27 UTC (permalink / raw)
  To: pve-devel

has to be explicitly enabled since this is only supported in
PVE

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 src/Makefile            |   1 +
 src/panel/TfaView.js    |  32 +++++++++
 src/window/AddYubico.js | 148 ++++++++++++++++++++++++++++++++++++++++
 src/window/TfaWindow.js |  31 ++++++++-
 4 files changed, 211 insertions(+), 1 deletion(-)
 create mode 100644 src/window/AddYubico.js

diff --git a/src/Makefile b/src/Makefile
index bf2eab0..d9d12e8 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -83,6 +83,7 @@ JSSRC=					\
 	window/AddTfaRecovery.js	\
 	window/AddTotp.js		\
 	window/AddWebauthn.js		\
+	window/AddYubico.js		\
 	window/TfaEdit.js		\
 	node/APT.js			\
 	node/APTRepositories.js		\
diff --git a/src/panel/TfaView.js b/src/panel/TfaView.js
index a0cb04a..712cdfe 100644
--- a/src/panel/TfaView.js
+++ b/src/panel/TfaView.js
@@ -18,11 +18,20 @@ Ext.define('pmx-tfa-entry', {
 Ext.define('Proxmox.panel.TfaView', {
     extend: 'Ext.grid.GridPanel',
     alias: 'widget.pmxTfaView',
+    mixins: ['Proxmox.Mixin.CBind'],
 
     title: gettext('Second Factors'),
     reference: 'tfaview',
 
     issuerName: 'Proxmox',
+    yubicoEnabled: false,
+
+    cbindData: function(initialConfig) {
+	let me = this;
+	return {
+	    yubicoEnabled: me.yubicoEnabled,
+	};
+    },
 
     store: {
 	type: 'diff',
@@ -116,6 +125,19 @@ Ext.define('Proxmox.panel.TfaView', {
 	    }).show();
 	},
 
+	addYubico: function() {
+	    let me = this;
+
+	    Ext.create('Proxmox.window.AddYubico', {
+		isCreate: true,
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+	    }).show();
+	},
+
 	editItem: function() {
 	    let me = this;
 	    let view = me.getView();
@@ -227,6 +249,7 @@ Ext.define('Proxmox.panel.TfaView', {
     tbar: [
 	{
 	    text: gettext('Add'),
+	    cbind: {},
 	    menu: {
 		xtype: 'menu',
 		items: [
@@ -248,6 +271,15 @@ Ext.define('Proxmox.panel.TfaView', {
 			iconCls: 'fa fa-fw fa-file-text-o',
 			handler: 'addRecovery',
 		    },
+		    {
+			text: gettext('Yubico'),
+			itemId: 'yubico',
+			iconCls: 'fa fa-fw fa-yahoo',
+			handler: 'addYubico',
+			cbind: {
+			    hidden: '{!yubicoEnabled}',
+			},
+		    },
 		],
 	    },
 	},
diff --git a/src/window/AddYubico.js b/src/window/AddYubico.js
new file mode 100644
index 0000000..22b884b
--- /dev/null
+++ b/src/window/AddYubico.js
@@ -0,0 +1,148 @@
+Ext.define('Proxmox.window.AddYubico', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pmxAddYubico',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'user_mgmt',
+
+    modal: true,
+    resizable: false,
+    title: gettext('Add a Yubico key'),
+    width: 512,
+
+    isAdd: true,
+    userid: undefined,
+    fixedUser: false,
+
+    initComponent: function() {
+	let me = this;
+	me.url = '/api2/extjs/access/tfa/';
+	me.method = 'POST';
+	me.callParent();
+    },
+
+    viewModel: {
+	data: {
+	    valid: false,
+	    userid: null,
+	},
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'field': {
+		validitychange: function(field, valid) {
+		    let me = this;
+		    let viewmodel = me.getViewModel();
+		    let form = me.lookup('yubico_form');
+		    viewmodel.set('valid', form.isValid());
+		},
+	    },
+	    '#': {
+		show: function() {
+		    let me = this;
+		    let view = me.getView();
+
+		    if (Proxmox.UserName === 'root@pam') {
+			view.lookup('password').setVisible(false);
+			view.lookup('password').setDisabled(true);
+		    }
+		},
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'yubico_form',
+	    layout: 'anchor',
+	    border: false,
+	    bodyPadding: 10,
+	    fieldDefaults: {
+		anchor: '100%',
+	    },
+	    items: [
+		{
+		    xtype: 'pmxDisplayEditField',
+		    name: 'userid',
+		    cbind: {
+			editable: (get) => !get('fixedUser'),
+			value: () => Proxmox.UserName,
+		    },
+		    fieldLabel: gettext('User'),
+		    editConfig: {
+			xtype: 'pmxUserSelector',
+			allowBlank: false,
+		    },
+		    renderer: Ext.String.htmlEncode,
+		    listeners: {
+			change: function(field, newValue, oldValue) {
+			    let vm = this.up('window').getViewModel();
+			    vm.set('userid', newValue);
+			},
+		    },
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Description'),
+		    allowBlank: false,
+		    name: 'description',
+		    maxLength: 256,
+		    emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Yubico OTP Key'),
+		    emptyText: gettext('A currently valid Yubico OTP value'),
+		    name: 'otp_value',
+		    maxLength: 44,
+		    enforceMaxLength: true,
+		    regex: /^[a-zA-Z0-9]{44}$/,
+		    regexText: '44 characters',
+		    maskRe: /^[a-zA-Z0-9]$/,
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'password',
+		    reference: 'password',
+		    fieldLabel: gettext('Verify Password'),
+		    inputType: 'password',
+		    minLength: 5,
+		    allowBlank: false,
+		    validateBlank: true,
+		    cbind: {
+			hidden: () => Proxmox.UserName === 'root@pam',
+			disabled: () => Proxmox.UserName === 'root@pam',
+			emptyText: () =>
+			    Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+		    },
+		},
+	    ],
+	},
+    ],
+
+    getValues: function(dirtyOnly) {
+	let me = this;
+
+	let values = me.callParent(arguments);
+
+	let uid = encodeURIComponent(values.userid);
+	me.url = `/api2/extjs/access/tfa/${uid}`;
+	delete values.userid;
+
+	let data = {
+	    description: values.description,
+	    type: "yubico",
+	    value: values.otp_value,
+	};
+
+	if (values.password) {
+	    data.password = values.password;
+	}
+
+	return data;
+    },
+});
diff --git a/src/window/TfaWindow.js b/src/window/TfaWindow.js
index 5026fb8..d568f9b 100644
--- a/src/window/TfaWindow.js
+++ b/src/window/TfaWindow.js
@@ -45,7 +45,7 @@ Ext.define('Proxmox.window.TfaLoginWindow', {
 
 	    let lastTabId = me.getLastTabUsed();
 	    let initialTab = -1, i = 0;
-	    for (const k of ['webauthn', 'totp', 'recovery', 'u2f']) {
+	    for (const k of ['webauthn', 'totp', 'recovery', 'u2f', 'yubico']) {
 		const available = !!challenge[k];
 		vm.set(`availableChallenge.${k}`, available);
 
@@ -143,6 +143,13 @@ Ext.define('Proxmox.window.TfaLoginWindow', {
 	    let _promise = me.finishChallenge(`totp:${code}`);
 	},
 
+	loginYubico: function() {
+	    let me = this;
+
+	    let code = me.lookup('yubico').getValue();
+	    let _promise = me.finishChallenge(`yubico:${code}`);
+	},
+
 	loginWebauthn: async function() {
 	    let me = this;
 	    let view = me.getView();
@@ -412,6 +419,28 @@ Ext.define('Proxmox.window.TfaLoginWindow', {
 		    },
 		],
 	    },
+	    {
+		xtype: 'panel',
+		title: gettext('Yubico OTP'),
+		iconCls: 'fa fa-fw fa-yahoo',
+		handler: 'loginYubico',
+		bind: {
+		    disabled: '{!availableChallenge.yubico}',
+		},
+		items: [
+		    {
+			xtype: 'textfield',
+			fieldLabel: gettext('Please enter your Yubico OTP code'),
+			labelWidth: 300,
+			name: 'yubico',
+			disabled: true,
+			reference: 'yubico',
+			allowBlank: false,
+			regex: /^[a-z0-9]{30,60}$/, // *should* be 44 but not sure if that's "fixed"
+			regexText: gettext('TOTP codes consist of six decimal digits'),
+		    },
+		],
+	    },
 	],
     }],
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied:  [PATCH common] Ticket: uri-escape colons
  2021-11-09 11:27 ` [pve-devel] [PATCH common] Ticket: uri-escape colons Wolfgang Bumiller
@ 2021-11-09 12:26   ` Thomas Lamprecht
  0 siblings, 0 replies; 43+ messages in thread
From: Thomas Lamprecht @ 2021-11-09 12:26 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

On 09.11.21 12:27, Wolfgang Bumiller wrote:
> Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> ---
>  src/PVE/Ticket.pm | 10 +++++++++-
>  1 file changed, 9 insertions(+), 1 deletion(-)
> 
>

applied, thanks!




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH widget-toolkit 1/7] add pmxUserSelector
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 1/7] add pmxUserSelector Wolfgang Bumiller
@ 2021-11-10  8:29   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:29 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

applied, but this has a weird dependency on 'pmx-users'
which must be defined in the product itself
(because they are not identical)

for now it's ok, but we should resolve that soon
(as soon as we want to reuse this in pbs i'd say)




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn Wolfgang Bumiller
@ 2021-11-10  8:30   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:30 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

applied




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow Wolfgang Bumiller
@ 2021-11-10  8:30   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:30 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

applied




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows Wolfgang Bumiller
@ 2021-11-10  8:30   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:30 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

applied




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView Wolfgang Bumiller
@ 2021-11-10  8:30   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:30 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

applied




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH widget-toolkit 7/7] add yubico otp windows & login support
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 7/7] add yubico otp windows & login support Wolfgang Bumiller
@ 2021-11-10  8:30   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:30 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

applied




^ permalink raw reply	[flat|nested] 43+ messages in thread

* Re: [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js Wolfgang Bumiller
@ 2021-11-10  8:31   ` Dominik Csapak
  0 siblings, 0 replies; 43+ messages in thread
From: Dominik Csapak @ 2021-11-10  8:31 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

not applied, we already have the 'libjs-qrcode' package we can use
and the u2f code could remain in pve-manager (since
we won't add it to other prodcuts now that we have webauthn)




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied: [PATCH cluster] add webauthn configuration to datacenter.cfg
  2021-11-09 11:27 ` [pve-devel] [PATCH cluster] add webauthn configuration to datacenter.cfg Wolfgang Bumiller
@ 2021-11-10 10:12   ` Thomas Lamprecht
  0 siblings, 0 replies; 43+ messages in thread
From: Thomas Lamprecht @ 2021-11-10 10:12 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

On 09.11.21 12:27, Wolfgang Bumiller wrote:
> Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> ---
>  data/PVE/DataCenterConfig.pm | 43 ++++++++++++++++++++++++++++++++++++
>  1 file changed, 43 insertions(+)
> 
>

applied, thanks!




^ permalink raw reply	[flat|nested] 43+ messages in thread

* [pve-devel] applied-series: [PATCH multiple 0/9] PBS-like TFA support in PVE
  2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
                   ` (31 preceding siblings ...)
  2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 7/7] add yubico otp windows & login support Wolfgang Bumiller
@ 2021-11-11 15:52 ` Thomas Lamprecht
  32 siblings, 0 replies; 43+ messages in thread
From: Thomas Lamprecht @ 2021-11-11 15:52 UTC (permalink / raw)
  To: Proxmox VE development discussion, Wolfgang Bumiller

On 09.11.21 12:26, Wolfgang Bumiller wrote:
> This is a bigger TFA upgrade for PVE.
> 
> This also contains the code for a new rust repository which will merge
> pve-rs and pmg-rs into 1 git repository.
> (git clone currently only available internally as my
> `proxmox-perl-rs.git` repository)
> 
> Most of the heavy lifting is now performed by the rust library.
> Note that the idea is that PVE and PBS can share this code directly, but
> for now the to-be-shared part is directly included here and will become
> its own crate after the initial PVE integration, as PBS will require a
> few changes (since the code originally hardcoded pbs types/paths/files...)
> 
> On the perl side this contains:
> 
> pve-common:
>   * A small change to the ticket code to url-escape colons in
>     the ticket data.
>     We also do this in pbs and since we only had usernames or base64
>     encoded tfa data in there this should be fine, and we want to store
>     JSON data directly there to be compatible with PBS.
> pve-cluster:
>   * Webauthn configuration in datacenter.cfg.
>     While PBS keeps this in the tfa json file, we already have the U2F
>     config in datacenter.cfg in PVE, so putting it into datacenter.cfg
>     seemed more consistent.
> proxmox-widget-toolkit:
>   * This series basically copies PBS' TFA code
> pve-manager:
>   * Update the login code to use the new workflow.
>   * Add the new TFA panel.
>   * Change the user TFA button to simply navigate to the new TFA panel
>     instead of popping up the old window.
> pve-access-control:
>   * Switch to the rust-parse for the tfa config.
>   * Update the login code to be more in line with PBS.
>   * Add the TFA API we have in PBS via the rust module.
> 

applied remaining access-control and pve-manager patches of this series, thanks!




^ permalink raw reply	[flat|nested] 43+ messages in thread

end of thread, other threads:[~2021-11-11 15:53 UTC | newest]

Thread overview: 43+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 1/6] import basic skeleton Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 2/6] import pve-rs Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 3/6] move apt to /perl-apt, use PERLMOD_PRODUCT env var Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 5/6] build fix: pmg-rs is not here yet Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 6/6] Add some dev tips to a README Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 01/10] use rust parser for TFA config Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 02/10] update read_user_tfa_type call Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 03/10] use PBS-like auth api call flow Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 04/10] handle yubico authentication in new path Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 05/10] move TFA api path into its own module Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 06/10] add pbs-style TFA API implementation Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 07/10] support registering yubico otp keys Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 08/10] update tfa cleanup when deleting users Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 09/10] pveum: update tfa delete command Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 10/10] set/remove 'x' for tfa keys in user.cfg in new api Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH cluster] add webauthn configuration to datacenter.cfg Wolfgang Bumiller
2021-11-10 10:12   ` [pve-devel] applied: " Thomas Lamprecht
2021-11-09 11:27 ` [pve-devel] [PATCH common] Ticket: uri-escape colons Wolfgang Bumiller
2021-11-09 12:26   ` [pve-devel] applied: " Thomas Lamprecht
2021-11-09 11:27 ` [pve-devel] [PATCH manager 1/7] www: use render_u2f_error from wtk Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 2/7] www: use UserSelector " Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 3/7] use u2f-api.js and qrcode.min.js " Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 4/7] www: switch to new tfa login format Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 5/7] www: use af-address-book-o for realms Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 6/7] www: add TFA view to config Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 7/7] www: redirect user TFA button to TFA view Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 1/7] add pmxUserSelector Wolfgang Bumiller
2021-11-10  8:29   ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn Wolfgang Bumiller
2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js Wolfgang Bumiller
2021-11-10  8:31   ` Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow Wolfgang Bumiller
2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows Wolfgang Bumiller
2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView Wolfgang Bumiller
2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 7/7] add yubico otp windows & login support Wolfgang Bumiller
2021-11-10  8:30   ` [pve-devel] applied: " Dominik Csapak
2021-11-11 15:52 ` [pve-devel] applied-series: [PATCH multiple 0/9] PBS-like TFA support in PVE Thomas Lamprecht

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal