Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
74fbcd1
OPEN-94: Add APDU recording and replay tools with a new `apdu-tools` …
tobias-schwerdtfeger Jan 5, 2026
878c652
OPEN-94: Add APDU recording and replay tools with a new `apdu-tools` …
tobias-schwerdtfeger Jan 5, 2026
3741c9e
OPEN-94: Add support for CAN field in APDU transcript handling
tobias-schwerdtfeger Jan 6, 2026
d14890f
OPEN-94: Add support for CAN field in APDU transcript handling
tobias-schwerdtfeger Jan 6, 2026
a5b7c0a
OPEN-94: Add license headers
tobias-schwerdtfeger Jan 6, 2026
cdcb542
Merge remote-tracking branch 'origin/main' into feature/open-94-healt…
tobias-schwerdtfeger Jan 7, 2026
2426765
OPEN-94: Consolidate license copyright years in headers
tobias-schwerdtfeger Jan 7, 2026
fa3dab9
Merge remote-tracking branch 'origin/main' into feature/open-94-healt…
tobias-schwerdtfeger Jan 8, 2026
98bef59
OPEN-94: Add `apdu-tools` feature flag and restructure PCSC integrati…
tobias-schwerdtfeger Jan 8, 2026
e942126
OPEN-94: Add documentation
tobias-schwerdtfeger Jan 8, 2026
c6c4ed6
OPEN-97: Add `cv_certificate` parser module for handling and decoding…
tobias-schwerdtfeger Jan 9, 2026
be3ef85
Add spec
tobias-schwerdtfeger Jan 9, 2026
196c840
Add spec
tobias-schwerdtfeger Jan 9, 2026
a5b879c
OPEN-99: Add support for selecting and retrieving multiple certificat…
SaGematik Jan 12, 2026
dfa43ad
OPEN-97: Add real world egk cvc test
tobias-schwerdtfeger Jan 12, 2026
57b4235
OPEN-97: Add license
tobias-schwerdtfeger Jan 12, 2026
064552d
Merge remote-tracking branch 'origin/feature/open-97-cvc-parser' into…
SaGematik Jan 12, 2026
2b45da3
OPEN-99: Add support for selecting and retrieving multiple certificat…
SaGematik Jan 12, 2026
24c7211
Merge remote-tracking branch 'origin/main' into feature/open-99-read-cvc
SaGematik Jan 12, 2026
0189be2
OPEN-99: Add optional dependencies `clap`, `serde`, `serde_json`, and…
SaGematik Jan 12, 2026
3b7a464
parameter formatting
SaGematik Jan 12, 2026
3c6485d
Refactor `fixed_key_generator` to return `EcKeyPairGenerator` directl…
SaGematik Jan 12, 2026
e2d2dd1
Simplify `fixed_key_generator` by inlining `keys.iter().map` logic fo…
SaGematik Jan 12, 2026
37e77e3
Refactor `fixed_key_generator` to wrap `FixedKeyGenerator` in a `Box`…
SaGematik Jan 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions core-modules/healthcard/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,7 @@ once_cell = "1.21.3"
num-bigint = "0.4"
zeroize = { version = "1.8.1", features = ["zeroize_derive"] }
uniffi = { version = "0.30.0", optional = true }
clap = { version = "4.5.52", features = ["derive"], optional = true }
pcsc = { version = "2.9.0", optional = true }
serde = { version = "1.0.228", features = ["derive"], optional = true }
serde_json = { version = "1.0.145", optional = true }

[dev-dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
clap = { version = "4.5.40", optional = true, features = ["derive"] }
serde = { version = "1.0.219", optional = true, features = ["derive"] }
serde_json = { version = "1.0.117", optional = true }
pcsc = { version = "2.8.2", optional = true }
25 changes: 24 additions & 1 deletion core-modules/healthcard/src/bin/apdu_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ fn main() {
use clap::Parser;
use crypto::ec::ec_key::{EcCurve, EcKeyPairSpec};
use healthcard::exchange::apdu_tools::{PcscChannel, RecordingChannel};
use healthcard::exchange::certificate::{retrieve_certificate_from, CertificateFile};
use healthcard::exchange::secure_channel::{establish_secure_channel_with, CardAccessNumber};

if let Err(err) = run() {
Expand Down Expand Up @@ -58,6 +59,9 @@ fn main() {
/// List available PC/SC readers and exit
#[arg(long)]
list_readers: bool,
/// Read certificates and print them as hex to stdout
#[arg(long)]
read_certificates: bool,
}

fn run() -> Result<(), String> {
Expand All @@ -80,13 +84,25 @@ fn main() {
recorder.set_can(can.clone());

let mut generated_keys = Vec::new();
establish_secure_channel_with(&mut recorder, &card_access_number, |curve: EcCurve| {
let mut secure_channel = establish_secure_channel_with(&mut recorder, &card_access_number, |curve: EcCurve| {
let (public_key, private_key) = EcKeyPairSpec { curve: curve.clone() }.generate_keypair()?;
generated_keys.push(hex::encode_upper(private_key.as_bytes()));
Ok((public_key, private_key))
})
.map_err(|err| format!("PACE failed: {err}"))?;

if args.read_certificates {
let cert = retrieve_certificate_from(&mut secure_channel, CertificateFile::ChAutE256)
.map_err(|err| format!("read DF.ESIGN/EF.C.CH.AUT.E256 failed: {err}"))?;
print_certificate("DF.ESIGN/EF.C.CH.AUT.E256", &cert);

let cert = retrieve_certificate_from(&mut secure_channel, CertificateFile::EgkAutCvcE256)
.map_err(|err| format!("read MF/EF.C.eGK.AUT_CVC.E256 failed: {err}"))?;
print_certificate("MF/EF.C.eGK.AUT_CVC.E256", &cert);
}

drop(secure_channel);

if !generated_keys.is_empty() {
recorder.set_keys(generated_keys);
}
Expand All @@ -96,6 +112,13 @@ fn main() {
Ok(())
}

fn print_certificate(label: &str, data: &[u8]) {
println!("{label} ({} bytes):", data.len());
for chunk in data.chunks(32) {
println!(" {}", hex::encode_upper(chunk));
}
}

fn list_pcsc_readers() -> Result<(), String> {
let ctx = pcsc::Context::establish(pcsc::Scope::User)
.map_err(|err| format!("pcsc context establish failed: {err}"))?;
Expand Down
10 changes: 5 additions & 5 deletions core-modules/healthcard/src/exchange/apdu_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ impl Transcript {
pub fn fixed_key_generator(&self) -> Result<Option<EcKeyPairGenerator>, TranscriptError> {
match &self.header.keys {
Some(keys) => {
let decoded = keys.iter().map(hex::decode).collect::<Result<Vec<_>, _>>()?;
Ok(Some(FixedKeyGenerator::new(decoded).generator()))
let decoded = keys.iter().map(hex::decode).collect::<Result<Vec<_>, hex::FromHexError>>()?;
Ok(Some(Box::new(FixedKeyGenerator::new(decoded).generator())))
}
None => Ok(None),
}
Expand Down Expand Up @@ -371,8 +371,8 @@ impl FixedKeyGenerator {
Self { keys }
}

pub fn generator(mut self) -> EcKeyPairGenerator {
Box::new(move |curve| {
pub fn generator(mut self) -> impl FnMut(EcCurve) -> Result<(EcPublicKey, EcPrivateKey), CryptoError> {
move |curve| {
if self.keys.is_empty() {
return Err(CryptoError::InvalidKeyMaterial { context: "fixed key generator ran out of keys" });
}
Expand All @@ -381,7 +381,7 @@ impl FixedKeyGenerator {
let (public_key, private_key) = derive_keypair_from_scalar(curve.clone(), bytes)?;
eprintln!("FixedKeyGenerator used key for {curve:?}: {key_hex}");
Ok((public_key, private_key))
})
}
}
}

Expand Down
84 changes: 77 additions & 7 deletions core-modules/healthcard/src/exchange/certificate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,43 @@ use super::channel::CardChannelExt;
use super::error::ExchangeError;
use super::ids;

/// Defines which certificate file to read from the card.
#[derive(Clone, Copy, Debug)]
pub enum CertificateFile {
/// X.509 certificate stored in `DF.ESIGN/EF.C.CH.AUT.E256`.
ChAutE256,
/// CV certificate stored in `MF/EF.C.eGK.AUT_CVC.E256`.
EgkAutCvcE256,
}

fn select_certificate_file<S>(session: &mut S, certificate: CertificateFile) -> Result<(), ExchangeError>
where
S: CardChannelExt,
{
match certificate {
CertificateFile::ChAutE256 => {
session.execute_command_success(&HealthCardCommand::select_aid(&ids::df_esign_aid()))?;
session.execute_command_success(&HealthCardCommand::select_fid_with_options(
&ids::ef_cch_aut_e256_fid(),
false,
true,
EXPECTED_LENGTH_WILDCARD_EXTENDED as i32,
))?;
}
CertificateFile::EgkAutCvcE256 => {
session.execute_command_success(&HealthCardCommand::select(false, false))?;
session.execute_command_success(&HealthCardCommand::select_fid_with_options(
&ids::ef_c_egk_aut_cvc_e256_fid(),
false,
true,
EXPECTED_LENGTH_WILDCARD_EXTENDED as i32,
))?;
}
}

Ok(())
}

/// Retrieve the X.509 certificate stored in `DF.ESIGN/EF.C.CH.AUT.E256`.
///
/// The certificate is read in chunks using the READ BINARY command until the
Expand All @@ -37,13 +74,18 @@ pub fn retrieve_certificate<S>(session: &mut S) -> Result<Vec<u8>, ExchangeError
where
S: CardChannelExt,
{
session.execute_command_success(&HealthCardCommand::select_aid(&ids::df_esign_aid()))?;
session.execute_command_success(&HealthCardCommand::select_fid_with_options(
&ids::ef_cch_aut_e256_fid(),
false,
true,
EXPECTED_LENGTH_WILDCARD_EXTENDED as i32,
))?;
retrieve_certificate_from(session, CertificateFile::ChAutE256)
}

/// Retrieve a certificate file from the card.
///
/// The certificate is read in chunks using the READ BINARY command until the
/// card indicates the end of the file.
pub fn retrieve_certificate_from<S>(session: &mut S, certificate: CertificateFile) -> Result<Vec<u8>, ExchangeError>
where
S: CardChannelExt,
{
select_certificate_file(session, certificate)?;

let mut certificate = Vec::new();
let mut offset: i32 = 0;
Expand Down Expand Up @@ -71,6 +113,7 @@ where
mod tests {
use super::*;
use crate::command::health_card_status::HealthCardResponseStatus;
use crate::command::select_command::SelectCommand;
use crate::exchange::test_utils::MockSession;

#[test]
Expand All @@ -95,4 +138,31 @@ mod tests {
other => panic!("unexpected error {other:?}"),
}
}

#[test]
fn cv_certificate_selects_master_file() {
let mut session = MockSession::with_extended_support(
vec![vec![0x90, 0x00], vec![0x90, 0x00], vec![0xDE, 0xAD, 0x90, 0x00], vec![0xBE, 0xEF, 0x62, 0x82]],
true,
);

let cert = retrieve_certificate_from(&mut session, CertificateFile::EgkAutCvcE256).unwrap();
assert_eq!(cert, vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(
session.recorded[0],
HealthCardCommand::select(false, false).command_apdu(false).unwrap().to_bytes()
);
assert_eq!(
session.recorded[1],
HealthCardCommand::select_fid_with_options(
&ids::ef_c_egk_aut_cvc_e256_fid(),
false,
true,
EXPECTED_LENGTH_WILDCARD_EXTENDED as i32,
)
.command_apdu(false)
.unwrap()
.to_bytes()
);
}
}
12 changes: 12 additions & 0 deletions core-modules/healthcard/src/exchange/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ pub fn ef_cch_aut_e256_sfid() -> ShortFileIdentifier {
short_file_identifier(0x04)
}

/// File identifier for `MF/EF.C.eGK.AUT_CVC.E256` (gemSpec_ObjSys Section 5.3.4).
pub fn ef_c_egk_aut_cvc_e256_fid() -> FileIdentifier {
file_identifier(0x2F06)
}

/// Short file identifier for `MF/EF.C.eGK.AUT_CVC.E256` (gemSpec_ObjSys Section 5.3.4).
pub fn ef_c_egk_aut_cvc_e256_sfid() -> ShortFileIdentifier {
short_file_identifier(0x06)
}

/// Key identifier for the `PrK.CH.AUT.E256` private key in `DF.ESIGN`.
pub fn prk_ch_aut_e256() -> CardKey {
CardKey::new(0x04).expect("constant key id must be valid")
Expand Down Expand Up @@ -135,6 +145,8 @@ mod tests {
assert_eq!(ef_status_vd_sfid().value(), 0x0C);
assert_eq!(ef_cch_aut_e256_fid().to_bytes(), [0xC5, 0x04]);
assert_eq!(ef_cch_aut_e256_sfid().value(), 0x04);
assert_eq!(ef_c_egk_aut_cvc_e256_fid().to_bytes(), [0x2F, 0x06]);
assert_eq!(ef_c_egk_aut_cvc_e256_sfid().value(), 0x06);

assert_eq!(prk_ch_aut_e256().key_id(), 0x04);
assert_eq!(mr_pin_home_reference().pwd_id(), 0x02);
Expand Down
10 changes: 10 additions & 0 deletions docs/tooling/apdu-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ cargo run -p healthcard --bin apdu_record --features apdu-tools,pcsc -- \
--out ./transcript.jsonl
```

To additionally read the certificates and print them to the console:

```sh
cargo run -p healthcard --bin apdu_record --features apdu-tools,pcsc -- \
--reader "<PCSC reader name>" \
--can 123123 \
--out ./transcript.jsonl \
--read-certificates
```

APDU length options:

- Default: uses extended-length APDUs when needed.
Expand Down