Skip to content

Commit d0d7d18

Browse files
authored
Merge pull request #4 from immz4/2fa-import-google-auth
Add Google Authenticator 2FA import
1 parent e2d0d93 commit d0d7d18

10 files changed

Lines changed: 442 additions & 24 deletions

File tree

.reuse/dep5

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,9 @@ Copyright:
298298
2017 The Tokio Authors
299299
2025 Foundation Devices, Inc. <hello@foundation.xyz>
300300
License: MIT OR Apache-2.0
301+
302+
Files:
303+
apps/gui-app-authenticator/proto/*
304+
Copyright:
305+
2026 Foundation Devices, Inc. <hello@foundation.xyz>
306+
License: MIT OR Apache-2.0

Cargo.lock

Lines changed: 38 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/gui-app-authenticator/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@ version = "0.1.0"
77
edition = "2021"
88

99
[dependencies]
10+
base32 = "0.4"
11+
base64 = "0.22.1"
1012
fs = { workspace = true }
1113
fuzzy-filter = "0.1.0"
1214
i18n = { path = "../../i18n" }
1315
log = "0.4.14"
1416
log-server = { package = "xous-api-log", path = "../../xous/api/log" }
1517
ordered-table = { path = "../../utils/ordered-table" }
18+
prost = "0.14.3"
19+
regex = "1"
1620
serde = { version = "1.0", features = ["derive"] }
1721
serde_json = "1.0"
1822
slint-keyos-platform = { path = "../../slint-keyos-platform/runtime" }
1923
thiserror = "1.0"
2024
totp-rs = { version = "5.6.0", features = ["otpauth", "serde_support"] }
25+
url = "2.5.8"
26+
urlencoding = "2.1.3"
2127

2228
[build-dependencies]
2329
slint-keyos-platform-build = { path = "../../slint-keyos-platform/build" }
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
syntax = "proto3";
2+
package google_auth_migration;
3+
4+
message MigrationPayload {
5+
enum Algorithm {
6+
ALGORITHM_UNSPECIFIED = 0;
7+
ALGORITHM_SHA1 = 1;
8+
ALGORITHM_SHA256 = 2;
9+
ALGORITHM_SHA512 = 3;
10+
}
11+
enum OtpType {
12+
OTP_TYPE_UNSPECIFIED = 0;
13+
OTP_TYPE_HOTP = 1;
14+
OTP_TYPE_TOTP = 2;
15+
}
16+
repeated OtpParameters otp_parameters = 1;
17+
int32 version = 2;
18+
int32 batch_size = 3;
19+
int32 batch_index = 4;
20+
int32 batch_id = 5;
21+
}
22+
message OtpParameters {
23+
bytes secret = 1;
24+
string name = 2;
25+
string issuer = 3;
26+
MigrationPayload.Algorithm algorithm = 4;
27+
int64 digits = 5;
28+
MigrationPayload.OtpType type = 6;
29+
int64 counter = 7;
30+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// This file is @generated by prost-build.
2+
#[derive(Clone, PartialEq, ::prost::Message)]
3+
pub struct MigrationPayload {
4+
#[prost(message, repeated, tag = "1")]
5+
pub otp_parameters: ::prost::alloc::vec::Vec<OtpParameters>,
6+
#[prost(int32, tag = "2")]
7+
pub version: i32,
8+
#[prost(int32, tag = "3")]
9+
pub batch_size: i32,
10+
#[prost(int32, tag = "4")]
11+
pub batch_index: i32,
12+
#[prost(int32, tag = "5")]
13+
pub batch_id: i32,
14+
}
15+
/// Nested message and enum types in `MigrationPayload`.
16+
pub mod migration_payload {
17+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
18+
#[repr(i32)]
19+
pub enum Algorithm {
20+
Unspecified = 0,
21+
Sha1 = 1,
22+
Sha256 = 2,
23+
Sha512 = 3,
24+
}
25+
impl Algorithm {
26+
/// String value of the enum field names used in the ProtoBuf definition.
27+
///
28+
/// The values are not transformed in any way and thus are considered stable
29+
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
30+
pub fn as_str_name(&self) -> &'static str {
31+
match self {
32+
Self::Unspecified => "ALGORITHM_UNSPECIFIED",
33+
Self::Sha1 => "ALGORITHM_SHA1",
34+
Self::Sha256 => "ALGORITHM_SHA256",
35+
Self::Sha512 => "ALGORITHM_SHA512",
36+
}
37+
}
38+
39+
/// Creates an enum from field names used in the ProtoBuf definition.
40+
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
41+
match value {
42+
"ALGORITHM_UNSPECIFIED" => Some(Self::Unspecified),
43+
"ALGORITHM_SHA1" => Some(Self::Sha1),
44+
"ALGORITHM_SHA256" => Some(Self::Sha256),
45+
"ALGORITHM_SHA512" => Some(Self::Sha512),
46+
_ => None,
47+
}
48+
}
49+
}
50+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
51+
#[repr(i32)]
52+
pub enum OtpType {
53+
Unspecified = 0,
54+
Hotp = 1,
55+
Totp = 2,
56+
}
57+
impl OtpType {
58+
/// String value of the enum field names used in the ProtoBuf definition.
59+
///
60+
/// The values are not transformed in any way and thus are considered stable
61+
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
62+
pub fn as_str_name(&self) -> &'static str {
63+
match self {
64+
Self::Unspecified => "OTP_TYPE_UNSPECIFIED",
65+
Self::Hotp => "OTP_TYPE_HOTP",
66+
Self::Totp => "OTP_TYPE_TOTP",
67+
}
68+
}
69+
70+
/// Creates an enum from field names used in the ProtoBuf definition.
71+
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
72+
match value {
73+
"OTP_TYPE_UNSPECIFIED" => Some(Self::Unspecified),
74+
"OTP_TYPE_HOTP" => Some(Self::Hotp),
75+
"OTP_TYPE_TOTP" => Some(Self::Totp),
76+
_ => None,
77+
}
78+
}
79+
}
80+
}
81+
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
82+
pub struct OtpParameters {
83+
#[prost(bytes = "vec", tag = "1")]
84+
pub secret: ::prost::alloc::vec::Vec<u8>,
85+
#[prost(string, tag = "2")]
86+
pub name: ::prost::alloc::string::String,
87+
#[prost(string, tag = "3")]
88+
pub issuer: ::prost::alloc::string::String,
89+
#[prost(enumeration = "migration_payload::Algorithm", tag = "4")]
90+
pub algorithm: i32,
91+
#[prost(int64, tag = "5")]
92+
pub digits: i64,
93+
#[prost(enumeration = "migration_payload::OtpType", tag = "6")]
94+
pub r#type: i32,
95+
#[prost(int64, tag = "7")]
96+
pub counter: i64,
97+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
use base64::engine::general_purpose::STANDARD as BASE64;
5+
use base64::Engine;
6+
use prost::Message;
7+
use regex::Regex;
8+
9+
use crate::{get_timestamp_in_seconds, Auth, AuthValidationError};
10+
11+
#[path = "../proto/google_auth_migration.rs"]
12+
mod proto;
13+
14+
use proto::migration_payload::{Algorithm, OtpType};
15+
16+
#[derive(Debug, thiserror::Error)]
17+
pub enum MigrationError {
18+
#[error("Invalid migration URI format")]
19+
InvalidUri,
20+
#[error("Failed to decode base64 data: {0}")]
21+
Base64DecodeError(#[from] base64::DecodeError),
22+
#[error("Protobuf decode error: {0}")]
23+
ProtobufDecodeError(String),
24+
#[error("All accounts were HOTP (unsupported)")]
25+
AllHotpSkipped,
26+
#[error("Auth validation error: {0}")]
27+
AuthValidationError(#[from] AuthValidationError),
28+
}
29+
30+
#[derive(Debug, Clone)]
31+
pub struct MigrationEntry {
32+
pub otpauth_url: String,
33+
pub label: String,
34+
pub account: String,
35+
pub issuer: String,
36+
}
37+
38+
pub fn is_migration_uri(uri: &str) -> bool { uri.starts_with("otpauth-migration://offline") }
39+
40+
pub fn parse_migration_uri(uri: &str) -> Result<Vec<Auth>, MigrationError> {
41+
let re = Regex::new(r"^otpauth-migration://offline\?(.*)$").unwrap();
42+
let caps = re.captures(uri).ok_or(MigrationError::InvalidUri)?;
43+
44+
let query_string = &caps[1];
45+
let data_param = extract_query_param(query_string, "data").ok_or(MigrationError::InvalidUri)?;
46+
47+
let decoded_data = urlencoding::decode(&data_param)
48+
.map_err(|e| MigrationError::ProtobufDecodeError(e.to_string()))?
49+
.into_owned();
50+
let decoded = BASE64.decode(decoded_data)?;
51+
52+
let payload = proto::MigrationPayload::decode(decoded.as_slice())
53+
.map_err(|e| MigrationError::ProtobufDecodeError(e.to_string()))?;
54+
55+
let mut entries = Vec::new();
56+
57+
for otp in &payload.otp_parameters {
58+
// Skip HOTP entries (type 1), only import TOTP (type 2)
59+
if otp.r#type != OtpType::Totp as i32 {
60+
log::info!("Skipping HOTP entry: {}", otp.name);
61+
continue;
62+
}
63+
64+
let algo_str = match otp.algorithm {
65+
x if x == Algorithm::Sha1 as i32 => "SHA1",
66+
x if x == Algorithm::Sha256 as i32 => "SHA256",
67+
x if x == Algorithm::Sha512 as i32 => "SHA512",
68+
_ => "SHA1",
69+
};
70+
71+
let secret = base32::encode(base32::Alphabet::RFC4648 { padding: false }, &otp.secret);
72+
73+
let label = if !otp.issuer.is_empty() { otp.issuer.clone() } else { otp.name.clone() };
74+
75+
let url = format!(
76+
// 6 digits fow now are hardcoded since otp.digits contains nonsense data
77+
// Example: it returns 1 for code with 6 digits
78+
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm={}&digits=6&period=30",
79+
urlencoding::encode(&label),
80+
urlencoding::encode(&otp.name),
81+
urlencoding::encode(&secret),
82+
urlencoding::encode(&label),
83+
urlencoding::encode(algo_str),
84+
);
85+
86+
let auth = Auth::new(url, get_timestamp_in_seconds())?;
87+
entries.push(auth);
88+
}
89+
90+
if entries.is_empty() {
91+
return Err(MigrationError::AllHotpSkipped);
92+
}
93+
94+
Ok(entries)
95+
}
96+
97+
fn extract_query_param(query: &str, key: &str) -> Option<String> {
98+
let re = Regex::new(&format!(r"{}=([^&]*)", regex::escape(key))).ok()?;
99+
let caps = re.captures(query)?;
100+
Some(caps[1].to_string())
101+
}

0 commit comments

Comments
 (0)