Fix the assumptions about authorization underlying the card::* types:
Multiple passwords can be validated on a card at the same time. Rename verify_* fn to be more easily legible ("user" instead of "pw1", ...)
This commit is contained in:
parent
1613f23ecc
commit
0e2b53feb4
2 changed files with 177 additions and 177 deletions
|
@ -5,7 +5,6 @@
|
||||||
//! different types, such as `Open`, `User`, `Sign`, `Admin`.
|
//! different types, such as `Open`, `User`, `Sign`, `Admin`.
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
|
|
||||||
use sequoia_openpgp::policy::Policy;
|
use sequoia_openpgp::policy::Policy;
|
||||||
use sequoia_openpgp::Cert;
|
use sequoia_openpgp::Cert;
|
||||||
|
@ -32,9 +31,28 @@ pub struct Open {
|
||||||
// FIXME: Should be invalidated when changing data on the card!
|
// FIXME: Should be invalidated when changing data on the card!
|
||||||
// (e.g. uploading keys, etc)
|
// (e.g. uploading keys, etc)
|
||||||
ard: ApplicationRelatedData,
|
ard: ApplicationRelatedData,
|
||||||
|
|
||||||
|
// verify status of pw1
|
||||||
|
pw1: bool,
|
||||||
|
|
||||||
|
// verify status of pw1 for signing
|
||||||
|
pw1_sign: bool,
|
||||||
|
|
||||||
|
// verify status of pw3
|
||||||
|
pw3: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Open {
|
impl Open {
|
||||||
|
fn new(card_app: CardApp, ard: ApplicationRelatedData) -> Self {
|
||||||
|
Self {
|
||||||
|
card_app,
|
||||||
|
ard,
|
||||||
|
pw1: false,
|
||||||
|
pw1_sign: false,
|
||||||
|
pw3: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set up connection to a CardClient (read and cache "application
|
/// Set up connection to a CardClient (read and cache "application
|
||||||
/// related data").
|
/// related data").
|
||||||
///
|
///
|
||||||
|
@ -47,7 +65,68 @@ impl Open {
|
||||||
|
|
||||||
card_app.init_caps(&ard)?;
|
card_app.init_caps(&ard)?;
|
||||||
|
|
||||||
Ok(Self { card_app, ard })
|
Ok(Self::new(card_app, ard))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_user(&mut self, pin: &str) -> Result<(), Error> {
|
||||||
|
let _ = self.card_app.verify_pw1(pin)?;
|
||||||
|
self.pw1 = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_user_for_signing(&mut self, pin: &str) -> Result<(), Error> {
|
||||||
|
let _ = self.card_app.verify_pw1_for_signing(pin)?;
|
||||||
|
|
||||||
|
// FIXME: depending on card mode, pw1_sign is only usable once
|
||||||
|
|
||||||
|
self.pw1_sign = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_admin(&mut self, pin: &str) -> Result<(), Error> {
|
||||||
|
let _ = self.card_app.verify_pw3(pin)?;
|
||||||
|
self.pw3 = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the card if the user password has been successfully verified.
|
||||||
|
///
|
||||||
|
/// NOTE: on some cards this functionality seems broken.
|
||||||
|
pub fn check_user_verified(&mut self) -> Result<Response, Error> {
|
||||||
|
self.card_app.check_pw1()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the card if the admin password has been successfully verified.
|
||||||
|
///
|
||||||
|
/// NOTE: on some cards this functionality seems broken.
|
||||||
|
pub fn check_admin_verified(&mut self) -> Result<Response, Error> {
|
||||||
|
self.card_app.check_pw3()
|
||||||
|
}
|
||||||
|
/// Get a view of the card authenticated for "User" commands.
|
||||||
|
pub fn get_user(&mut self) -> Option<User> {
|
||||||
|
if self.pw1 {
|
||||||
|
Some(User { oc: self })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a view of the card authenticated for Signing.
|
||||||
|
pub fn get_sign(&mut self) -> Option<Sign> {
|
||||||
|
if self.pw1_sign {
|
||||||
|
Some(Sign { oc: self })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a view of the card authenticated for "Admin" commands.
|
||||||
|
pub fn get_admin(&mut self) -> Option<Admin> {
|
||||||
|
if self.pw3 {
|
||||||
|
Some(Admin { oc: self })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- application data ---
|
// --- application data ---
|
||||||
|
@ -172,134 +251,49 @@ impl Open {
|
||||||
pub fn factory_reset(&mut self) -> Result<()> {
|
pub fn factory_reset(&mut self) -> Result<()> {
|
||||||
self.card_app.factory_reset()
|
self.card_app.factory_reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_pw1_for_signing(mut self, pin: &str) -> Result<Sign, Open> {
|
|
||||||
assert!(pin.len() >= 6); // FIXME: Err
|
|
||||||
|
|
||||||
if self.card_app.verify_pw1_for_signing(pin).is_ok() {
|
|
||||||
Ok(Sign { oc: self })
|
|
||||||
} else {
|
|
||||||
Err(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_pw1(&mut self) -> Result<Response, Error> {
|
|
||||||
self.card_app.check_pw1()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_pw1(mut self, pin: &str) -> Result<User, Open> {
|
|
||||||
assert!(pin.len() >= 6); // FIXME: Err
|
|
||||||
|
|
||||||
if self.card_app.verify_pw1(pin).is_ok() {
|
|
||||||
Ok(User { oc: self })
|
|
||||||
} else {
|
|
||||||
Err(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_pw3(&mut self) -> Result<Response, Error> {
|
|
||||||
self.card_app.check_pw3()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_pw3(mut self, pin: &str) -> Result<Admin, Open> {
|
|
||||||
assert!(pin.len() >= 8); // FIXME: Err
|
|
||||||
|
|
||||||
if self.card_app.verify_pw3(pin).is_ok() {
|
|
||||||
Ok(Admin { oc: self })
|
|
||||||
} else {
|
|
||||||
Err(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An OpenPGP card after successful verification of PW1 in mode 82
|
/// An OpenPGP card after successfully verifying PW1 in mode 82
|
||||||
/// (verification for operations other than signing)
|
/// (verification for user operations other than signing)
|
||||||
pub struct User {
|
pub struct User<'a> {
|
||||||
oc: Open,
|
oc: &'a mut Open,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow access to fn of OpenPGPCard, through OpenPGPCardUser.
|
impl User<'_> {
|
||||||
impl Deref for User {
|
|
||||||
type Target = Open;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.oc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow access to fn of CardBase, through CardUser.
|
|
||||||
impl DerefMut for User {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.oc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl User {
|
|
||||||
pub fn decryptor(
|
pub fn decryptor(
|
||||||
&mut self,
|
&mut self,
|
||||||
cert: &Cert,
|
cert: &Cert,
|
||||||
policy: &dyn Policy,
|
policy: &dyn Policy,
|
||||||
) -> Result<CardDecryptor, Error> {
|
) -> Result<CardDecryptor, Error> {
|
||||||
CardDecryptor::new(&mut self.card_app, cert, policy)
|
CardDecryptor::new(&mut self.oc.card_app, cert, policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An OpenPGP card after successful verification of PW1 in mode 81
|
/// An OpenPGP card after successfully verifying PW1 in mode 81
|
||||||
/// (verification for signing)
|
/// (verification for signing)
|
||||||
pub struct Sign {
|
pub struct Sign<'a> {
|
||||||
oc: Open,
|
oc: &'a mut Open,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow access to fn of CardBase, through CardSign.
|
impl Sign<'_> {
|
||||||
impl Deref for Sign {
|
|
||||||
type Target = Open;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.oc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow access to fn of CardBase, through CardSign.
|
|
||||||
impl DerefMut for Sign {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.oc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: depending on the setting in "PW1 Status byte", only one
|
|
||||||
// signature can be made after verification for signing
|
|
||||||
impl Sign {
|
|
||||||
pub fn signer(
|
pub fn signer(
|
||||||
&mut self,
|
&mut self,
|
||||||
cert: &Cert,
|
cert: &Cert,
|
||||||
policy: &dyn Policy,
|
policy: &dyn Policy,
|
||||||
) -> std::result::Result<CardSigner, Error> {
|
) -> std::result::Result<CardSigner, Error> {
|
||||||
CardSigner::new(&mut self.card_app, cert, policy)
|
// FIXME: depending on the setting in "PW1 Status byte", only one
|
||||||
|
// signature can be made after verification for signing
|
||||||
|
|
||||||
|
CardSigner::new(&mut self.oc.card_app, cert, policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An OpenPGP card after successful verification of PW3 ("Admin privileges")
|
/// An OpenPGP card after successful verification of PW3 ("Admin privileges")
|
||||||
pub struct Admin {
|
pub struct Admin<'a> {
|
||||||
oc: Open,
|
oc: &'a mut Open,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow access to fn of OpenPGPCard, through OpenPGPCardAdmin.
|
impl Admin<'_> {
|
||||||
impl Deref for Admin {
|
|
||||||
type Target = Open;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.oc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow access to fn of OpenPGPCard, through OpenPGPCardAdmin.
|
|
||||||
impl DerefMut for Admin {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.oc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Admin {
|
|
||||||
pub fn set_name(&mut self, name: &str) -> Result<Response, Error> {
|
pub fn set_name(&mut self, name: &str) -> Result<Response, Error> {
|
||||||
if name.len() >= 40 {
|
if name.len() >= 40 {
|
||||||
return Err(anyhow!("name too long").into());
|
return Err(anyhow!("name too long").into());
|
||||||
|
@ -310,7 +304,7 @@ impl Admin {
|
||||||
return Err(anyhow!("Invalid char in name").into());
|
return Err(anyhow!("Invalid char in name").into());
|
||||||
};
|
};
|
||||||
|
|
||||||
self.card_app.set_name(name)
|
self.oc.card_app.set_name(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_lang(&mut self, lang: &str) -> Result<Response, Error> {
|
pub fn set_lang(&mut self, lang: &str) -> Result<Response, Error> {
|
||||||
|
@ -318,11 +312,11 @@ impl Admin {
|
||||||
return Err(anyhow!("lang too long").into());
|
return Err(anyhow!("lang too long").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.card_app.set_lang(lang)
|
self.oc.card_app.set_lang(lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_sex(&mut self, sex: Sex) -> Result<Response, Error> {
|
pub fn set_sex(&mut self, sex: Sex) -> Result<Response, Error> {
|
||||||
self.card_app.set_sex(sex)
|
self.oc.card_app.set_sex(sex)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_url(&mut self, url: &str) -> Result<Response, Error> {
|
pub fn set_url(&mut self, url: &str) -> Result<Response, Error> {
|
||||||
|
@ -331,10 +325,10 @@ impl Admin {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for max len
|
// Check for max len
|
||||||
let ec = self.get_extended_capabilities()?;
|
let ec = self.oc.get_extended_capabilities()?;
|
||||||
|
|
||||||
if url.len() < ec.max_len_special_do() as usize {
|
if url.len() < ec.max_len_special_do() as usize {
|
||||||
self.card_app.set_url(url)
|
self.oc.card_app.set_url(url)
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("URL too long").into())
|
Err(anyhow!("URL too long").into())
|
||||||
}
|
}
|
||||||
|
@ -345,6 +339,6 @@ impl Admin {
|
||||||
key: Box<dyn CardUploadableKey>,
|
key: Box<dyn CardUploadableKey>,
|
||||||
key_type: KeyType,
|
key_type: KeyType,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
self.card_app.key_import(key, key_type)
|
self.oc.card_app.key_import(key, key_type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,56 +102,58 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// ---------------------------------------------
|
// ---------------------------------------------
|
||||||
assert_eq!(app_id.ident(), test_card_ident);
|
assert_eq!(app_id.ident(), test_card_ident);
|
||||||
|
|
||||||
let check = oc.check_pw3();
|
let check = oc.check_admin_verified();
|
||||||
println!("has pw3 been verified yet? {:x?}\n", check);
|
println!("has pw3 been verified yet? {:x?}\n", check);
|
||||||
|
|
||||||
println!("factory reset");
|
println!("factory reset");
|
||||||
oc.factory_reset()?;
|
oc.factory_reset()?;
|
||||||
|
|
||||||
match oc.verify_pw3("12345678") {
|
if oc.verify_admin("12345678").is_ok() {
|
||||||
Ok(mut oc_admin) => {
|
println!("pw3 verify ok");
|
||||||
println!("pw3 verify ok");
|
|
||||||
|
|
||||||
let check = oc_admin.check_pw3();
|
let check = oc.check_user_verified();
|
||||||
println!("has pw3 been verified yet? {:x?}", check);
|
println!("has pw1/82 been verified yet? {:x?}", check);
|
||||||
|
|
||||||
let res = oc_admin.set_name("Bar<<Foo")?;
|
// actually take Admin
|
||||||
println!("set name {:x?}", res);
|
let mut oc_admin = oc.get_admin().expect("just verified");
|
||||||
|
|
||||||
let res = oc_admin.set_sex(Sex::NotApplicable)?;
|
let res = oc_admin.set_name("Bar<<Foo")?;
|
||||||
println!("set sex {:x?}", res);
|
println!("set name {:x?}", res);
|
||||||
|
|
||||||
let res = oc_admin.set_lang("en")?;
|
let res = oc_admin.set_sex(Sex::NotApplicable)?;
|
||||||
println!("set lang {:x?}", res);
|
println!("set sex {:x?}", res);
|
||||||
|
|
||||||
let res = oc_admin.set_url("https://keys.openpgp.org")?;
|
let res = oc_admin.set_lang("en")?;
|
||||||
println!("set url {:x?}", res);
|
println!("set lang {:x?}", res);
|
||||||
|
|
||||||
let cert = Cert::from_file(TEST_KEY_PATH)?;
|
let res = oc_admin.set_url("https://keys.openpgp.org")?;
|
||||||
|
println!("set url {:x?}", res);
|
||||||
|
|
||||||
openpgp_card_sequoia::util::upload_from_cert_yolo(
|
let cert = Cert::from_file(TEST_KEY_PATH)?;
|
||||||
&mut oc_admin,
|
|
||||||
&cert,
|
|
||||||
KeyType::Decryption,
|
|
||||||
None,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
openpgp_card_sequoia::util::upload_from_cert_yolo(
|
openpgp_card_sequoia::util::upload_from_cert_yolo(
|
||||||
&mut oc_admin,
|
&mut oc_admin,
|
||||||
&cert,
|
&cert,
|
||||||
KeyType::Signing,
|
KeyType::Decryption,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// TODO: test keys currently have no auth-capable key
|
openpgp_card_sequoia::util::upload_from_cert_yolo(
|
||||||
// openpgp_card_sequoia::upload_from_cert(
|
&mut oc_admin,
|
||||||
// &oc_admin,
|
&cert,
|
||||||
// &cert,
|
KeyType::Signing,
|
||||||
// KeyType::Authentication,
|
None,
|
||||||
// None,
|
)?;
|
||||||
// )?;
|
|
||||||
}
|
// TODO: test keys currently have no auth-capable key
|
||||||
_ => panic!(),
|
// openpgp_card_sequoia::upload_from_cert(
|
||||||
|
// &oc_admin,
|
||||||
|
// &cert,
|
||||||
|
// KeyType::Authentication,
|
||||||
|
// None,
|
||||||
|
// )?;
|
||||||
|
} else {
|
||||||
|
panic!()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
@ -171,40 +173,42 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// Check that we're still using the expected card
|
// Check that we're still using the expected card
|
||||||
assert_eq!(app_id.ident(), test_card_ident);
|
assert_eq!(app_id.ident(), test_card_ident);
|
||||||
|
|
||||||
let check = oc.check_pw1();
|
let check = oc.check_user_verified();
|
||||||
println!("has pw1/82 been verified yet? {:x?}", check);
|
println!("has pw1/82 been verified yet? {:x?}", check);
|
||||||
|
|
||||||
match oc.verify_pw1("123456") {
|
if oc.verify_user("123456").is_ok() {
|
||||||
Ok(mut oc_user) => {
|
println!("pw1 82 verify ok");
|
||||||
println!("pw1 82 verify ok");
|
|
||||||
|
|
||||||
let check = oc_user.check_pw1();
|
let check = oc.check_user_verified();
|
||||||
println!("has pw1/82 been verified yet? {:x?}", check);
|
println!("has pw1/82 been verified yet? {:x?}", check);
|
||||||
|
|
||||||
let cert = Cert::from_file(TEST_KEY_PATH)?;
|
// actually take User
|
||||||
let msg = std::fs::read_to_string(TEST_ENC_MSG)
|
let mut oc_user = oc.get_user().expect("just verified");
|
||||||
.expect("Unable to read file");
|
|
||||||
|
|
||||||
println!("{:?}", msg);
|
let cert = Cert::from_file(TEST_KEY_PATH)?;
|
||||||
|
let msg = std::fs::read_to_string(TEST_ENC_MSG)
|
||||||
|
.expect("Unable to read file");
|
||||||
|
|
||||||
let sp = StandardPolicy::new();
|
println!("{:?}", msg);
|
||||||
|
|
||||||
let d = oc_user.decryptor(&cert, &sp)?;
|
let sp = StandardPolicy::new();
|
||||||
|
|
||||||
let res = decryption_helper(d, msg.into_bytes(), &sp)?;
|
let d = oc_user.decryptor(&cert, &sp)?;
|
||||||
|
|
||||||
let plain = String::from_utf8_lossy(&res);
|
let res = decryption_helper(d, msg.into_bytes(), &sp)?;
|
||||||
println!("decrypted plaintext: {}", plain);
|
|
||||||
|
|
||||||
assert_eq!(plain, "Hello world!\n");
|
let plain = String::from_utf8_lossy(&res);
|
||||||
}
|
println!("decrypted plaintext: {}", plain);
|
||||||
_ => panic!("verify pw1 failed"),
|
|
||||||
|
assert_eq!(plain, "Hello world!\n");
|
||||||
|
} else {
|
||||||
|
panic!("verify pw1 failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Open fresh Card for signing
|
// Open fresh Card for signing
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
let oc =
|
let mut oc =
|
||||||
Open::open_card(PcscClient::open_by_ident(&test_card_ident)?)?;
|
Open::open_card(PcscClient::open_by_ident(&test_card_ident)?)?;
|
||||||
|
|
||||||
// let oc = CardBase::open_card(ScdClient::open_by_serial(
|
// let oc = CardBase::open_card(ScdClient::open_by_serial(
|
||||||
|
@ -213,24 +217,26 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// )?)?;
|
// )?)?;
|
||||||
|
|
||||||
// Sign
|
// Sign
|
||||||
match oc.verify_pw1_for_signing("123456") {
|
if oc.verify_user_for_signing("123456").is_ok() {
|
||||||
Ok(mut oc_sign) => {
|
println!("pw1 81 verify ok");
|
||||||
println!("pw1 81 verify ok");
|
|
||||||
|
|
||||||
let cert = Cert::from_file(TEST_KEY_PATH)?;
|
// actually take Sign
|
||||||
|
let mut oc_sign = oc.get_sign().expect("just verified");
|
||||||
|
|
||||||
let text = "Hello world, I am signed.";
|
let cert = Cert::from_file(TEST_KEY_PATH)?;
|
||||||
|
|
||||||
let signer = oc_sign.signer(&cert, &StandardPolicy::new())?;
|
let text = "Hello world, I am signed.";
|
||||||
let res = sign_helper(signer, &mut text.as_bytes());
|
|
||||||
|
|
||||||
println!("res sign {:?}", res);
|
let signer = oc_sign.signer(&cert, &StandardPolicy::new())?;
|
||||||
|
let res = sign_helper(signer, &mut text.as_bytes());
|
||||||
|
|
||||||
println!("res: {}", res?)
|
println!("res sign {:?}", res);
|
||||||
|
|
||||||
// FIXME: validate sig
|
println!("res: {}", res?)
|
||||||
}
|
|
||||||
_ => panic!("verify pw1 failed"),
|
// FIXME: validate sig
|
||||||
|
} else {
|
||||||
|
panic!("verify pw1 failed");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("Please set environment variable TEST_CARD_IDENT.");
|
println!("Please set environment variable TEST_CARD_IDENT.");
|
||||||
|
|
Loading…
Reference in a new issue