hardware-enclave 0.2.7

Hardware-backed key management — macOS Secure Enclave, Windows TPM 2.0, Linux TPM/keyring — plus in-process memory protection
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
// Copyright 2026 Jay Gowdy
// SPDX-License-Identifier: MIT

//! Win32 WebAuthn FFI wrapper. All `unsafe` is contained here; the
//! public API surface in `lib.rs` is safe Rust.
//!
//! ## clientDataJSON brittleness note
//!
//! Win32 WebAuthn.dll documents `pbClientDataJSON` as bytes-of-JSON
//! plus an explicit `pwszHashAlgId` -- it does not validate that the
//! bytes parse as JSON. We exploit this contract: `client_data` is
//! the raw SSH sign payload, and webauthn.dll computes
//! `SHA-256(client_data)` and signs `authenticator_data || that_hash`.
//! The OpenSSH SK verifier reconstructs the same shape from
//! `SHA-256(data)` where `data` is the SSH session-binding bytes.
//!
//! If a future Windows update tightens this and starts requiring
//! that `pbClientDataJSON` actually be valid JSON, every existing
//! sshenc Hello user breaks. The integration test in
//! `tests/wire_format_roundtrip.rs` asserts the WebAuthn-produced
//! signature verifies via `ssh-keygen -Y verify`, so any such drift
//! is caught at CI time rather than silently in production.
#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]

use ciborium::Value as CborValue;
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;

use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND;
use windows::Win32::Networking::WindowsWebServices::*;
use windows::Win32::System::Console::GetConsoleWindow;
use windows::Win32::UI::WindowsAndMessaging::{
    GetDesktopWindow, GetForegroundWindow, IsWindowVisible,
};

use super::{Result, WebAuthnAssertion, WebAuthnCredential, WebAuthnError};

/// Parameters for `make_credential`.
#[derive(Debug, Clone)]
pub struct MakeCredentialParams<'params> {
    /// Relying-Party identifier. We pin it to a stable string per
    /// app (`"sshenc"`, `"awsenc"`, etc.) so credentials don't
    /// collide across enclave apps on the same host.
    pub rp_id: &'params str,
    /// Human-readable RP name surfaced in the Hello prompt.
    pub rp_name: &'params str,
    /// Per-user opaque identifier the OS scopes credentials by.
    /// We use the SSH key label (or a hash of it) here.
    pub user_id: &'params [u8],
    /// Username surfaced in the Hello prompt and in `passkey` UI.
    pub user_name: &'params str,
    /// Display name surfaced in the Hello prompt.
    pub user_display_name: &'params str,
    /// Hello prompt timeout. Hard upper bound is whatever the OS
    /// applies; pick something the user can realistically respond
    /// to (60s is the WebAuthn convention).
    pub timeout_ms: u32,
    /// HWND to parent the prompt to. `None` -> auto-pick from
    /// `GetConsoleWindow`/`GetForegroundWindow`/`GetDesktopWindow`
    /// in that order. CLI binaries should pass `None`; the agent
    /// (where there is no console) should pass an explicit handle
    /// from a foreground helper.
    pub hwnd: Option<isize>,
}

/// Parameters for `get_assertion`.
#[derive(Debug, Clone)]
pub struct GetAssertionParams<'params> {
    /// Must match the `rp_id` used at make-credential time.
    pub rp_id: &'params str,
    /// `credential_id` returned from a prior `make_credential` for
    /// the user we're signing as.
    pub credential_id: &'params [u8],
    /// Raw bytes the SSH side wants signed. Will be SHA-256'd by
    /// webauthn.dll and that hash will be concatenated with
    /// `authenticator_data` and ECDSA-signed.
    pub client_data: &'params [u8],
    pub timeout_ms: u32,
    pub hwnd: Option<isize>,
}

/// Remove a previously-registered platform credential from the
/// user's passkey list. Best-effort -- if the credential is
/// already gone (user pruned it via Settings -> Passkeys) this
/// returns an error we generally want to ignore.
pub fn delete_platform_credential(credential_id: &[u8]) -> Result<()> {
    #[allow(unsafe_code)]
    unsafe {
        WebAuthNDeletePlatformCredential(credential_id).map_err(map_webauthn_error)
    }
}

/// Probe whether the Hello platform authenticator is reachable on
/// this host. Cheap check (no UI). Use to decide at install/keygen
/// time whether to recommend the SK key path.
pub fn is_platform_authenticator_available() -> bool {
    #[allow(unsafe_code)]
    unsafe {
        match WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable() {
            Ok(b) => b.as_bool(),
            Err(_) => false,
        }
    }
}

/// Create a new TPM-backed credential. Triggers a one-time Hello
/// "save your passkey" / consent prompt; the user's gesture proves
/// presence and the resulting credential is sealed by the TPM.
pub fn make_credential(params: MakeCredentialParams<'_>) -> Result<WebAuthnCredential> {
    let rp_id_w = to_wide(params.rp_id);
    let rp_name_w = to_wide(params.rp_name);
    let user_name_w = to_wide(params.user_name);
    let user_display_w = to_wide(params.user_display_name);

    let mut user_id_buf: Vec<u8> = params.user_id.to_vec();
    let mut client_data_json = canonical_make_client_data();

    #[allow(unsafe_code)]
    let result = unsafe {
        let hwnd = pick_hwnd(params.hwnd);

        let rp = WEBAUTHN_RP_ENTITY_INFORMATION {
            dwVersion: WEBAUTHN_RP_ENTITY_INFORMATION_CURRENT_VERSION,
            pwszId: PCWSTR(rp_id_w.as_ptr()),
            pwszName: PCWSTR(rp_name_w.as_ptr()),
            pwszIcon: PCWSTR::null(),
        };

        let user = WEBAUTHN_USER_ENTITY_INFORMATION {
            dwVersion: WEBAUTHN_USER_ENTITY_INFORMATION_CURRENT_VERSION,
            cbId: u32::try_from(user_id_buf.len())
                .map_err(|_| WebAuthnError::InvalidResponse("user_id too long".into()))?,
            pbId: user_id_buf.as_mut_ptr(),
            pwszName: PCWSTR(user_name_w.as_ptr()),
            pwszIcon: PCWSTR::null(),
            pwszDisplayName: PCWSTR(user_display_w.as_ptr()),
        };

        let mut cose_param = WEBAUTHN_COSE_CREDENTIAL_PARAMETER {
            dwVersion: 1,
            pwszCredentialType: WEBAUTHN_CREDENTIAL_TYPE_PUBLIC_KEY,
            lAlg: WEBAUTHN_COSE_ALGORITHM_ECDSA_P256_WITH_SHA256,
        };
        let cose_params = WEBAUTHN_COSE_CREDENTIAL_PARAMETERS {
            cCredentialParameters: 1,
            pCredentialParameters: &mut cose_param,
        };

        let client_data = WEBAUTHN_CLIENT_DATA {
            dwVersion: WEBAUTHN_CLIENT_DATA_CURRENT_VERSION,
            cbClientDataJSON: u32::try_from(client_data_json.len())
                .map_err(|_| WebAuthnError::InvalidResponse("client_data_json too long".into()))?,
            pbClientDataJSON: client_data_json.as_mut_ptr(),
            pwszHashAlgId: WEBAUTHN_HASH_ALGORITHM_SHA_256,
        };

        let opts = WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS {
            dwVersion: WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS_VERSION_1,
            dwTimeoutMilliseconds: params.timeout_ms,
            dwAuthenticatorAttachment: WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM,
            dwUserVerificationRequirement: WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED,
            dwAttestationConveyancePreference: WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
            bRequireResidentKey: false.into(),
            bPreferResidentKey: false.into(),
            ..Default::default()
        };

        let opts_ptr: *const WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS = &opts;
        WebAuthNAuthenticatorMakeCredential(
            hwnd,
            &rp,
            &user,
            &cose_params,
            &client_data,
            Some(opts_ptr),
        )
    };

    let attestation_ptr = result.map_err(map_webauthn_error)?;

    // SAFETY: Win32 returned a non-null attestation pointer on Ok.
    // We deref once, copy fields out, and free on the way out.
    #[allow(unsafe_code)]
    let credential = unsafe {
        let att = &*attestation_ptr;
        let credential_id = slice_from_raw(att.pbCredentialId, att.cbCredentialId).to_vec();
        let authenticator_data =
            slice_from_raw(att.pbAuthenticatorData, att.cbAuthenticatorData).to_vec();
        let resident = att.bResidentKey.as_bool();

        WebAuthNFreeCredentialAttestation(Some(attestation_ptr));

        let (x, y) = parse_pubkey_from_authenticator_data(&authenticator_data)?;
        WebAuthnCredential {
            credential_id,
            public_key_x: x,
            public_key_y: y,
            authenticator_data,
            resident,
        }
    };

    Ok(credential)
}

/// Sign `client_data` with a previously-created credential. Fires
/// a Hello prompt; on user verification the TPM returns a DER
/// ECDSA signature over `authenticator_data || SHA-256(client_data)`.
pub fn get_assertion(params: GetAssertionParams<'_>) -> Result<WebAuthnAssertion> {
    let rp_id_w = to_wide(params.rp_id);
    let mut credential_id_buf: Vec<u8> = params.credential_id.to_vec();
    let mut client_data_buf: Vec<u8> = params.client_data.to_vec();

    #[allow(unsafe_code)]
    let result = unsafe {
        let hwnd = pick_hwnd(params.hwnd);

        // Use the V1 `CredentialList` field (a `WEBAUTHN_CREDENTIALS`
        // of plain `WEBAUTHN_CREDENTIAL`) and explicitly null
        // `pAllowCredentialList`. This matches the
        // `tavrez/openssh-sk-winhello` working pattern. With the V4
        // `pAllowCredentialList` field the platform on some Win11
        // builds enumerates discoverable credentials more
        // aggressively for the chooser; with V1 + a single-entry
        // CredentialList scoped to a unique-per-key RP, the chooser
        // collapses to a single-entry "OK" interstitial instead of
        // a multi-credential picker.
        let mut allow_cred = WEBAUTHN_CREDENTIAL {
            dwVersion: 1,
            cbId: u32::try_from(credential_id_buf.len())
                .map_err(|_| WebAuthnError::InvalidResponse("credential_id too long".into()))?,
            pbId: credential_id_buf.as_mut_ptr(),
            pwszCredentialType: WEBAUTHN_CREDENTIAL_TYPE_PUBLIC_KEY,
        };
        let allow_list = WEBAUTHN_CREDENTIALS {
            cCredentials: 1,
            pCredentials: &mut allow_cred,
        };

        let client_data = WEBAUTHN_CLIENT_DATA {
            dwVersion: WEBAUTHN_CLIENT_DATA_CURRENT_VERSION,
            cbClientDataJSON: u32::try_from(client_data_buf.len())
                .map_err(|_| WebAuthnError::InvalidResponse("client_data too long".into()))?,
            pbClientDataJSON: client_data_buf.as_mut_ptr(),
            pwszHashAlgId: WEBAUTHN_HASH_ALGORITHM_SHA_256,
        };

        let opts = WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS {
            dwVersion: 1,
            dwTimeoutMilliseconds: params.timeout_ms,
            CredentialList: allow_list,
            dwAuthenticatorAttachment: WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM,
            dwUserVerificationRequirement: WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED,
            pAllowCredentialList: std::ptr::null_mut(),
            ..Default::default()
        };

        let opts_ptr: *const WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS = &opts;
        WebAuthNAuthenticatorGetAssertion(
            hwnd,
            PCWSTR(rp_id_w.as_ptr()),
            &client_data,
            Some(opts_ptr),
        )
    };

    let assertion_ptr = result.map_err(map_webauthn_error)?;

    #[allow(unsafe_code)]
    let assertion = unsafe {
        let asn = &*assertion_ptr;
        let authenticator_data =
            slice_from_raw(asn.pbAuthenticatorData, asn.cbAuthenticatorData).to_vec();
        let signature_der = slice_from_raw(asn.pbSignature, asn.cbSignature).to_vec();
        WebAuthNFreeAssertion(assertion_ptr);

        if authenticator_data.len() < 37 {
            return Err(WebAuthnError::InvalidResponse(format!(
                "authenticator_data too short: {} bytes",
                authenticator_data.len()
            )));
        }
        let flags = authenticator_data[32];
        let counter = u32::from_be_bytes([
            authenticator_data[33],
            authenticator_data[34],
            authenticator_data[35],
            authenticator_data[36],
        ]);
        WebAuthnAssertion {
            signature_der,
            authenticator_data,
            flags,
            counter,
        }
    };

    Ok(assertion)
}

// ---- internals ---------------------------------------------------

fn to_wide(s: &str) -> Vec<u16> {
    OsStr::new(s)
        .encode_wide()
        .chain(std::iter::once(0))
        .collect()
}

#[allow(unsafe_code)]
/// Pick an HWND to parent the Hello dialog to.
///
/// Probes (in order): caller-provided HWND, console window,
/// foreground window, desktop. Each candidate is required to be
/// **visible** (`IsWindowVisible`) — if the chosen parent is
/// hidden, Hello renders the dialog parented to a non-displayable
/// window, the user can't respond to it, and
/// `WebAuthNAuthenticatorMakeCredential` / `…GetAssertion` block
/// indefinitely (the dwTimeoutMilliseconds field doesn't trip
/// once the user has interacted with the dialog at all). Field-
/// observed when a sshenc matrix run was launched from
/// `Start-Process -WindowStyle Hidden`: GetConsoleWindow() returned
/// the hidden parent's HWND, the first dialog squeaked through to
/// foreground but every subsequent prompt landed on the hidden
/// parent and the keygen process hung forever.
///
/// The desktop fallback is always visible, so we always have a
/// usable HWND. A caller-provided HWND that fails the visibility
/// check is swapped for the next candidate rather than respected
/// blindly — the caller's intent ("parent the dialog HERE")
/// presumes the parent is displayable; honoring an invisible HWND
/// just hangs the dialog.
unsafe fn pick_hwnd(provided: Option<isize>) -> HWND {
    let candidates: [HWND; 4] = [
        provided
            .map(|raw| HWND(raw as *mut _))
            .unwrap_or(HWND(std::ptr::null_mut())),
        GetConsoleWindow(),
        GetForegroundWindow(),
        GetDesktopWindow(),
    ];
    for candidate in candidates {
        if !candidate.0.is_null() && IsWindowVisible(candidate).as_bool() {
            return candidate;
        }
    }
    // Should be unreachable: GetDesktopWindow is always non-null
    // and visible. Fall through to it anyway in case Win32 returns
    // something unexpected.
    GetDesktopWindow()
}

#[allow(unsafe_code)]
unsafe fn slice_from_raw<'params>(ptr: *const u8, len: u32) -> &'params [u8] {
    if ptr.is_null() || len == 0 {
        return &[];
    }
    std::slice::from_raw_parts(ptr, len as usize)
}

fn map_webauthn_error(e: windows::core::Error) -> WebAuthnError {
    let hr = e.code();
    // 0x80090028 NTE_USER_CANCELLED
    if hr.0 as u32 == 0x80090028 {
        return WebAuthnError::UserCanceled;
    }
    // 0x800704C7 ERROR_CANCELLED (WinRT cancellation)
    if hr.0 as u32 == 0x800704C7 {
        return WebAuthnError::UserCanceled;
    }
    // 0x80004004 E_ABORT (WebAuthn cancellation)
    if hr.0 as u32 == 0x80004004 {
        return WebAuthnError::UserCanceled;
    }
    // 0x800705B4 ERROR_TIMEOUT
    if hr.0 as u32 == 0x800705B4 {
        return WebAuthnError::Timeout;
    }
    let name = lookup_error_name(hr);
    WebAuthnError::Backend {
        hr: hr.0 as u32,
        name,
    }
}

#[allow(unsafe_code)]
fn lookup_error_name(hr: windows::core::HRESULT) -> String {
    unsafe {
        let pw = WebAuthNGetErrorName(hr);
        if pw.0.is_null() {
            return String::from("(unnamed)");
        }
        let mut len = 0_usize;
        while *pw.0.add(len) != 0 {
            len += 1;
        }
        let slice = std::slice::from_raw_parts(pw.0, len);
        String::from_utf16_lossy(slice)
    }
}

/// At make-credential time we don't carry an SSH-side payload,
/// so we synthesize a minimal but well-formed JSON envelope. This
/// is the only place we put real JSON in `pbClientDataJSON` -- at
/// sign time we put the raw SSH bytes per the SK protocol.
fn canonical_make_client_data() -> Vec<u8> {
    br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"sshenc:keygen"}"#.to_vec()
}

/// Parse the ECDSA P-256 public key out of `authenticator_data`.
///
/// Layout per FIDO2 / W3C WebAuthn:
/// ```text
///   bytes  0..32  rpIdHash
///   byte   32     flags
///   bytes  33..37 signCount (u32 BE)
///   bytes  37..   attestedCredentialData (only when AT flag set):
///                 16 bytes aaguid
///                 2 bytes  credentialIdLength (u16 BE)
///                 N bytes  credentialId
///                 M bytes  credentialPublicKey (COSE_Key CBOR map)
/// ```
///
/// The COSE_Key map for ECDSA P-256 (kty=2, alg=-7) contains
/// `-2 (x)` and `-3 (y)` -- each a 32-byte byte string. We
/// CBOR-decode rather than offset-hardcode (which is what the
/// `tavrez/openssh-sk-winhello` plugin does -- works today, breaks
/// quietly if Microsoft ever reorders fields).
fn parse_pubkey_from_authenticator_data(authenticator_data: &[u8]) -> Result<([u8; 32], [u8; 32])> {
    if authenticator_data.len() < 37 {
        return Err(WebAuthnError::InvalidResponse(format!(
            "authenticator_data too short for header: {} bytes",
            authenticator_data.len()
        )));
    }
    let flags = authenticator_data[32];
    if flags & 0x40 == 0 {
        return Err(WebAuthnError::InvalidResponse(
            "AT flag not set in authenticator_data; cannot extract pubkey".into(),
        ));
    }

    let attested_start = 37;
    if authenticator_data.len() < attested_start + 18 {
        return Err(WebAuthnError::InvalidResponse(
            "authenticator_data too short for attested credential header".into(),
        ));
    }
    // skip 16-byte AAGUID
    let cred_len_off = attested_start + 16;
    let cred_id_len = u16::from_be_bytes([
        authenticator_data[cred_len_off],
        authenticator_data[cred_len_off + 1],
    ]) as usize;
    let cose_start = cred_len_off + 2 + cred_id_len;
    if authenticator_data.len() < cose_start {
        return Err(WebAuthnError::InvalidResponse(
            "authenticator_data too short for COSE_Key blob".into(),
        ));
    }
    let cose_bytes = &authenticator_data[cose_start..];

    let cose_value: CborValue = ciborium::from_reader(cose_bytes)
        .map_err(|e| WebAuthnError::InvalidResponse(format!("COSE CBOR parse failed: {e}")))?;
    let map = match cose_value {
        CborValue::Map(m) => m,
        _ => {
            return Err(WebAuthnError::InvalidResponse(
                "COSE_Key value is not a CBOR map".into(),
            ))
        }
    };

    let mut x: Option<[u8; 32]> = None;
    let mut y: Option<[u8; 32]> = None;
    for (k, v) in map.iter() {
        let k_int = match k {
            CborValue::Integer(i) => i128::from(*i),
            _ => continue,
        };
        let v_bytes = match v {
            CborValue::Bytes(b) => b.as_slice(),
            _ => continue,
        };
        if k_int == -2 && v_bytes.len() == 32 {
            let mut buf = [0_u8; 32];
            buf.copy_from_slice(v_bytes);
            x = Some(buf);
        } else if k_int == -3 && v_bytes.len() == 32 {
            let mut buf = [0_u8; 32];
            buf.copy_from_slice(v_bytes);
            y = Some(buf);
        }
    }

    match (x, y) {
        (Some(x), Some(y)) => Ok((x, y)),
        _ => Err(WebAuthnError::InvalidResponse(
            "COSE_Key missing -2/-3 (x/y) byte strings".into(),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Build a minimal authenticator_data with a synthetic
    /// COSE_Key payload to exercise the parser without hardware.
    fn synth_authenticator_data(x: &[u8; 32], y: &[u8; 32]) -> Vec<u8> {
        let mut cose_bytes = Vec::new();
        let cose = CborValue::Map(vec![
            (CborValue::Integer(1.into()), CborValue::Integer(2.into())), // kty=EC2
            (
                CborValue::Integer(3.into()),
                CborValue::Integer((-7_i32).into()),
            ), // alg=ES256
            (
                CborValue::Integer((-1_i32).into()),
                CborValue::Integer(1.into()),
            ), // crv=P-256
            (
                CborValue::Integer((-2_i32).into()),
                CborValue::Bytes(x.to_vec()),
            ),
            (
                CborValue::Integer((-3_i32).into()),
                CborValue::Bytes(y.to_vec()),
            ),
        ]);
        ciborium::into_writer(&cose, &mut cose_bytes).expect("cbor encode");

        let mut ad = Vec::new();
        ad.extend_from_slice(&[0_u8; 32]); // rpIdHash
        ad.push(0x45); // flags: UP|UV|AT
        ad.extend_from_slice(&0_u32.to_be_bytes()); // counter
        ad.extend_from_slice(&[0_u8; 16]); // aaguid
        ad.extend_from_slice(&16_u16.to_be_bytes()); // credentialIdLength
        ad.extend_from_slice(&[0_u8; 16]); // credentialId
        ad.extend_from_slice(&cose_bytes);
        ad
    }

    #[test]
    fn parses_ecdsa_p256_pubkey() {
        let mut x = [0_u8; 32];
        let mut y = [0_u8; 32];
        for i in 0..32 {
            x[i] = i as u8;
            y[i] = (i + 32) as u8;
        }
        let ad = synth_authenticator_data(&x, &y);
        let (got_x, got_y) = parse_pubkey_from_authenticator_data(&ad).expect("parse ok");
        assert_eq!(got_x, x);
        assert_eq!(got_y, y);
    }

    #[test]
    fn rejects_truncated_authenticator_data() {
        let short = vec![0_u8; 36];
        assert!(parse_pubkey_from_authenticator_data(&short).is_err());
    }

    #[test]
    fn rejects_missing_at_flag() {
        let mut ad = vec![0_u8; 64];
        ad[32] = 0x05; // UP|UV but not AT
        assert!(parse_pubkey_from_authenticator_data(&ad).is_err());
    }
}