1use std::collections::{HashMap, HashSet};
2use std::fs::{self, File, OpenOptions};
3use std::io::Write;
4use std::ops::Deref;
5use std::os::unix::ffi::OsStrExt;
6use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
7use std::path::{Path, PathBuf};
8use std::ptr::NonNull;
9use std::sync::atomic::{Ordering, compiler_fence};
10use std::sync::{Arc, Mutex, MutexGuard};
11
12use anyhow::{Context, Result, anyhow, bail};
13use chacha20poly1305::aead::{Aead, KeyInit, Payload};
14use chacha20poly1305::{XChaCha20Poly1305, XNonce};
15use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
16use rand::RngExt as _;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use soft_fido2::{Credential, CredentialBackupState, CredentialRef, PinState, StatusCode};
20use tempfile::Builder;
21use zeroize::{Zeroize, Zeroizing};
22
23use crate::application::CredentialSummary;
24
25const VAULT_ROOT: &str = "/var/lib/auc";
26const IDENTITY_FILE: &str = "identity.cbor";
27const EVENTS_DIRECTORY: &str = "events";
28const PIN_STATE_FILE: &str = "pin-state.cbor";
29const IDENTITY_SCHEMA: u16 = 1;
30const EVENT_SCHEMA: u16 = 1;
31const PIN_SCHEMA: u16 = 1;
32const MAX_IDENTITY_BYTES: u64 = 4096;
33const MAX_EVENT_BYTES: u64 = 256 * 1024;
34const MAX_PIN_BYTES: u64 = 16 * 1024;
35const MAX_CREDENTIALS: usize = 4096;
36const EVENT_DOMAIN: &[u8] = b"auc-credential-event-v1\0";
37const EVENT_SIGNATURE_DOMAIN: &[u8] = b"auc-credential-event-signature-v1\0";
38const PIN_DOMAIN: &[u8] = b"auc-device-local-pin-state-v1\0";
39
40#[derive(Clone)]
41pub struct Vault {
42 inner: Arc<Mutex<VaultState>>,
43}
44
45impl Vault {
46 pub fn open() -> Result<Self> {
47 if !rustix::process::geteuid().is_root() {
48 bail!("auc vault requires root");
49 }
50 Self::open_path(Path::new(VAULT_ROOT), 0, 0)
51 }
52
53 pub fn purge() -> Result<()> {
54 if !rustix::process::geteuid().is_root() {
55 bail!("auc vault purge requires root");
56 }
57 drop(Self::open()?);
58 let root = Path::new(VAULT_ROOT);
59 let metadata = fs::symlink_metadata(root)?;
60 if !metadata.file_type().is_dir()
61 || metadata.uid() != 0
62 || metadata.gid() != 0
63 || metadata.mode() & 0o7777 != 0o700
64 {
65 bail!("auc vault root failed ownership, type, or mode validation");
66 }
67 for entry in fs::read_dir(root)? {
68 let entry = entry?;
69 let name = entry.file_name();
70 if !matches!(
71 name.to_str(),
72 Some(IDENTITY_FILE | EVENTS_DIRECTORY | PIN_STATE_FILE | "access-policy.json")
73 ) {
74 bail!(
75 "refusing to purge unexpected path in the auc vault: {}",
76 entry.path().display()
77 );
78 }
79 let metadata = fs::symlink_metadata(entry.path())?;
80 if name == EVENTS_DIRECTORY {
81 if !metadata.file_type().is_dir()
82 || metadata.uid() != 0
83 || metadata.gid() != 0
84 || metadata.mode() & 0o7777 != 0o700
85 {
86 bail!("auc events path changed before vault purge");
87 }
88 } else if !metadata.file_type().is_file()
89 || metadata.uid() != 0
90 || metadata.gid() != 0
91 || metadata.mode() & 0o7777 != 0o600
92 {
93 bail!("auc private file changed before vault purge");
94 }
95 }
96 fs::remove_dir_all(root).context("failed to remove the validated auc vault")?;
97 File::open("/var/lib")?.sync_all()?;
98 Ok(())
99 }
100
101 #[cfg(test)]
102 fn open_for_test(path: &Path) -> Result<Self> {
103 Self::open_path(
104 path,
105 rustix::process::geteuid().as_raw(),
106 rustix::process::getegid().as_raw(),
107 )
108 }
109
110 fn open_path(root: &Path, uid: u32, gid: u32) -> Result<Self> {
111 ensure_secure_directory(root, uid, gid)?;
112 remove_uncommitted_files(root, ".auc-identity-", uid, gid)?;
113 remove_uncommitted_files(root, ".auc-pin-", uid, gid)?;
114 let events = root.join(EVENTS_DIRECTORY);
115 ensure_secure_directory(&events, uid, gid)?;
116 remove_uncommitted_files(&events, ".auc-event-", uid, gid)?;
117 let identity = load_or_create_identity(root, uid, gid)?;
118 let mut state = VaultState {
119 root: root.to_path_buf(),
120 uid,
121 gid,
122 vault_key: LockedSecret::new(identity.vault_key)?,
123 signing_seed: LockedSecret::new(identity.signing_key)?,
124 device_id: identity.device_id,
125 credentials: HashMap::new(),
126 tombstones: HashSet::new(),
127 event_ids: HashSet::new(),
128 next_sequence: 1,
129 last_hash: [0; 32],
130 pin_version: 0,
131 };
132 state.load_events()?;
133 state.pin_version = state.load_pin_state()?.version;
134 Ok(Self {
135 inner: Arc::new(Mutex::new(state)),
136 })
137 }
138
139 pub fn device_unique_name(&self) -> Result<String> {
140 Ok(format!("auc-{}", hex::encode(self.lock()?.device_id)))
141 }
142
143 pub fn write_credential(&self, credential: &CredentialRef<'_>) -> Result<()> {
144 let mut credential = credential.to_owned();
145 credential.sign_count = 0;
146 credential.backup_state = CredentialBackupState::Eligible;
147 self.lock()?.append(EventPayload::Upsert {
148 credential: credential
149 .to_bytes()
150 .map_err(|_| anyhow!("soft-fido2 failed to encode a newly created credential"))?,
151 })
152 }
153
154 pub fn read_credential(&self, credential_id: &[u8]) -> Result<Option<Credential>> {
155 Ok(self.lock()?.credentials.get(credential_id).cloned())
156 }
157
158 pub fn delete_credential(&self, credential_id: &[u8]) -> Result<bool> {
159 let mut state = self.lock()?;
160 if !state.credentials.contains_key(credential_id) {
161 return Ok(false);
162 }
163 state.append(EventPayload::Tombstone {
164 credential_id: credential_id.to_vec(),
165 })?;
166 Ok(true)
167 }
168
169 pub fn list_credentials(&self, rp_id: &str, user_id: Option<&[u8]>) -> Result<Vec<Credential>> {
170 let state = self.lock()?;
171 let mut credentials = state
172 .credentials
173 .values()
174 .filter(|credential| {
175 credential.rp.id == rp_id
176 && user_id.is_none_or(|user_id| credential.user.id == user_id)
177 })
178 .cloned()
179 .collect::<Vec<_>>();
180 credentials.sort_by(|left, right| left.id.cmp(&right.id));
181 Ok(credentials)
182 }
183
184 pub fn all_credentials(&self) -> Result<Vec<Credential>> {
185 let state = self.lock()?;
186 let mut credentials = state.credentials.values().cloned().collect::<Vec<_>>();
187 credentials.sort_by(|left, right| {
188 left.rp
189 .id
190 .cmp(&right.rp.id)
191 .then_with(|| left.id.cmp(&right.id))
192 });
193 Ok(credentials)
194 }
195
196 pub fn credential_summaries(&self) -> Result<Vec<CredentialSummary>> {
197 Ok(self
198 .all_credentials()?
199 .into_iter()
200 .map(|credential| CredentialSummary {
201 credential_id: hex::encode(&credential.id),
202 rp_id: credential.rp.id,
203 user_name: credential.user.name,
204 discoverable: credential.discoverable,
205 backup_eligible: credential.backup_state.is_eligible(),
206 backed_up: credential.backup_state.is_backed_up(),
207 })
208 .collect())
209 }
210
211 pub fn credential_count(&self) -> Result<usize> {
212 Ok(self.lock()?.credentials.len())
213 }
214
215 pub fn enumerate_rps(&self) -> Result<Vec<(String, Option<String>, usize)>> {
216 let state = self.lock()?;
217 let mut relying_parties = HashMap::<String, (Option<String>, usize)>::new();
218 for credential in state.credentials.values() {
219 let entry = relying_parties
220 .entry(credential.rp.id.clone())
221 .or_insert_with(|| (credential.rp.name.clone(), 0));
222 entry.1 += 1;
223 }
224 let mut relying_parties = relying_parties
225 .into_iter()
226 .map(|(id, (name, count))| (id, name, count))
227 .collect::<Vec<_>>();
228 relying_parties.sort_by(|left, right| left.0.cmp(&right.0));
229 Ok(relying_parties)
230 }
231
232 pub fn pin_storage(&self) -> VaultPinStorage {
233 VaultPinStorage {
234 vault: self.clone(),
235 }
236 }
237
238 fn lock(&self) -> Result<MutexGuard<'_, VaultState>> {
239 self.inner
240 .lock()
241 .map_err(|_| anyhow!("auc vault lock was poisoned"))
242 }
243}
244
245#[derive(Clone)]
246pub struct VaultPinStorage {
247 vault: Vault,
248}
249
250impl soft_fido2::PinStorageCallbacks for VaultPinStorage {
251 fn load_pin_state(&self) -> std::result::Result<PinState, StatusCode> {
252 self.vault
253 .lock()
254 .and_then(|state| state.load_pin_state())
255 .map_err(|error| {
256 eprintln!("auc PIN state load failed: {error:#}");
257 StatusCode::Other
258 })
259 }
260
261 fn save_pin_state(&self, state: &PinState) -> std::result::Result<(), StatusCode> {
262 self.vault
263 .lock()
264 .and_then(|mut vault| vault.save_pin_state(state))
265 .map_err(|error| {
266 eprintln!("auc PIN state save failed: {error:#}");
267 StatusCode::Other
268 })
269 }
270}
271
272struct VaultState {
273 root: PathBuf,
274 uid: u32,
275 gid: u32,
276 vault_key: LockedSecret,
277 signing_seed: LockedSecret,
278 device_id: [u8; 16],
279 credentials: HashMap<Vec<u8>, Credential>,
280 tombstones: HashSet<Vec<u8>>,
281 event_ids: HashSet<[u8; 16]>,
282 next_sequence: u64,
283 last_hash: [u8; 32],
284 pin_version: u64,
285}
286
287impl VaultState {
288 fn cipher(&self) -> XChaCha20Poly1305 {
289 XChaCha20Poly1305::new_from_slice(&*self.vault_key)
290 .expect("a locked auc vault key always has 32 bytes")
291 }
292
293 fn signing_key(&self) -> SigningKey {
294 SigningKey::from_bytes(&self.signing_seed)
295 }
296
297 fn load_events(&mut self) -> Result<()> {
298 let mut paths = fs::read_dir(self.root.join(EVENTS_DIRECTORY))?
299 .map(|entry| entry.map(|entry| entry.path()))
300 .collect::<std::io::Result<Vec<_>>>()?;
301 paths.sort();
302 for path in paths {
303 let name = path
304 .file_name()
305 .and_then(|name| name.to_str())
306 .ok_or_else(|| anyhow!("auc event filename is not UTF-8"))?;
307 if !name.ends_with(".cbor") {
308 bail!("unexpected file in auc event store: {name}");
309 }
310 let bytes = read_private_file(&path, self.uid, self.gid, MAX_EVENT_BYTES)
311 .with_context(|| format!("failed to read auc event {name}"))?;
312 let envelope: EventEnvelope = ciborium::from_reader(bytes.as_slice())
313 .with_context(|| format!("failed to decode auc event {name}"))?;
314 let envelope_hash: [u8; 32] = Sha256::digest(&bytes).into();
315 self.apply_envelope(name, &envelope, &envelope_hash)?;
316 self.last_hash = envelope_hash;
317 self.next_sequence = self
318 .next_sequence
319 .checked_add(1)
320 .ok_or_else(|| anyhow!("auc event sequence overflow"))?;
321 }
322 Ok(())
323 }
324
325 fn apply_envelope(
326 &mut self,
327 filename: &str,
328 envelope: &EventEnvelope,
329 envelope_hash: &[u8; 32],
330 ) -> Result<()> {
331 envelope
332 .validate(
333 self.next_sequence,
334 self.last_hash,
335 &self.signing_key().verifying_key(),
336 )
337 .with_context(|| format!("auc event validation failed for {filename}"))?;
338 let expected_name = event_filename(envelope.sequence, &envelope.event_id, envelope_hash);
339 if filename != expected_name {
340 bail!("auc event filename does not match its authenticated identity: {filename}");
341 }
342 if !self.event_ids.insert(envelope.event_id) {
343 bail!("auc event contains a duplicate event ID: {filename}");
344 }
345 let associated_data = envelope.associated_data();
346 let plaintext = Zeroizing::new(
347 self.cipher()
348 .decrypt(
349 &XNonce::from(envelope.nonce),
350 Payload {
351 msg: &envelope.ciphertext,
352 aad: &associated_data,
353 },
354 )
355 .map_err(|_| anyhow!("auc event AEAD authentication failed: {filename}"))?,
356 );
357 let payload = Zeroizing::new(
358 ciborium::from_reader::<EventPayload, _>(plaintext.as_slice())
359 .with_context(|| format!("auc event payload is malformed: {filename}"))?,
360 );
361 if payload.domain() != envelope.payload_domain {
362 bail!("auc event payload domain mismatch: {filename}");
363 }
364 self.apply_payload(&payload)
365 .with_context(|| format!("auc event payload is invalid: {filename}"))
366 }
367
368 fn apply_payload(&mut self, payload: &EventPayload) -> Result<()> {
369 match payload {
370 EventPayload::Upsert { credential } => {
371 let credential = Credential::from_bytes(credential)
372 .map_err(|_| anyhow!("credential CBOR is invalid"))?;
373 validate_credential(&credential)?;
374 if self.tombstones.contains(&credential.id) {
375 bail!("credential tombstone cannot be resurrected");
376 }
377 if !self.credentials.contains_key(&credential.id)
378 && self.credentials.len() >= MAX_CREDENTIALS
379 {
380 bail!("credential store exceeds its safety limit");
381 }
382 self.credentials.insert(credential.id.clone(), credential);
383 }
384 EventPayload::Tombstone { credential_id } => {
385 validate_credential_id(credential_id)?;
386 self.credentials.remove(credential_id.as_slice());
387 self.tombstones.insert(credential_id.clone());
388 }
389 }
390 Ok(())
391 }
392
393 fn append(&mut self, payload: EventPayload) -> Result<()> {
394 let payload = Zeroizing::new(payload);
395 self.apply_payload_validation(&payload)?;
396 let mut payload_bytes = Zeroizing::new(Vec::new());
397 ciborium::into_writer(&*payload, &mut *payload_bytes)?;
398 if payload_bytes.len() > MAX_EVENT_BYTES as usize / 2 {
399 bail!("auc credential event payload is too large");
400 }
401 let mut event_id = [0_u8; 16];
402 let mut nonce = [0_u8; 24];
403 let mut random = rand::rng();
404 loop {
405 random.fill(&mut event_id);
406 if !self.event_ids.contains(&event_id) {
407 break;
408 }
409 }
410 random.fill(&mut nonce);
411 let signing_key = self.signing_key();
412 let mut envelope = EventEnvelope {
413 schema: EVENT_SCHEMA,
414 sequence: self.next_sequence,
415 event_id,
416 previous_hash: self.last_hash,
417 signer: signing_key.verifying_key().to_bytes(),
418 payload_domain: payload.domain(),
419 nonce,
420 ciphertext: Vec::new(),
421 signature: Vec::new(),
422 };
423 let associated_data = envelope.associated_data();
424 envelope.ciphertext = self
425 .cipher()
426 .encrypt(
427 &XNonce::from(nonce),
428 Payload {
429 msg: &payload_bytes,
430 aad: &associated_data,
431 },
432 )
433 .map_err(|_| anyhow!("failed to encrypt auc credential event"))?;
434 envelope.signature = signing_key
435 .sign(&envelope.signature_message())
436 .to_bytes()
437 .to_vec();
438 let mut bytes = Vec::new();
439 ciborium::into_writer(&envelope, &mut bytes)?;
440 if bytes.len() > MAX_EVENT_BYTES as usize {
441 bail!("auc credential event envelope is too large");
442 }
443 let event_hash: [u8; 32] = Sha256::digest(&bytes).into();
444 let destination = self.root.join(EVENTS_DIRECTORY).join(event_filename(
445 self.next_sequence,
446 &event_id,
447 &event_hash,
448 ));
449 persist_new_private_file(&destination, &bytes, ".auc-event-", self.uid, self.gid)?;
450 self.apply_payload(&payload)?;
451 self.event_ids.insert(event_id);
452 self.last_hash = event_hash;
453 self.next_sequence = self
454 .next_sequence
455 .checked_add(1)
456 .ok_or_else(|| anyhow!("auc event sequence overflow"))?;
457 Ok(())
458 }
459
460 fn apply_payload_validation(&self, payload: &EventPayload) -> Result<()> {
461 match payload {
462 EventPayload::Upsert { credential } => {
463 let credential = Credential::from_bytes(credential)
464 .map_err(|_| anyhow!("credential CBOR is invalid"))?;
465 validate_credential(&credential)?;
466 if self.tombstones.contains(&credential.id) {
467 bail!("credential tombstone cannot be resurrected");
468 }
469 if !self.credentials.contains_key(&credential.id)
470 && self.credentials.len() >= MAX_CREDENTIALS
471 {
472 bail!("credential store exceeds its safety limit");
473 }
474 Ok(())
475 }
476 EventPayload::Tombstone { credential_id } => validate_credential_id(credential_id),
477 }
478 }
479
480 fn load_pin_state(&self) -> Result<PinState> {
481 let path = self.root.join(PIN_STATE_FILE);
482 let bytes = match read_private_file(&path, self.uid, self.gid, MAX_PIN_BYTES) {
483 Ok(bytes) => bytes,
484 Err(error)
485 if error
486 .downcast_ref::<std::io::Error>()
487 .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
488 {
489 return Ok(PinState::default());
490 }
491 Err(error) => return Err(error),
492 };
493 let envelope: PinEnvelope =
494 ciborium::from_reader(bytes.as_slice()).context("failed to decode auc PIN envelope")?;
495 if envelope.schema != PIN_SCHEMA {
496 bail!("unsupported auc PIN state schema {}", envelope.schema);
497 }
498 let associated_data = pin_associated_data(envelope.version);
499 let plaintext = Zeroizing::new(
500 self.cipher()
501 .decrypt(
502 &XNonce::from(envelope.nonce),
503 Payload {
504 msg: &envelope.ciphertext,
505 aad: &associated_data,
506 },
507 )
508 .map_err(|_| anyhow!("auc PIN state authentication failed"))?,
509 );
510 let state: PinState =
511 ciborium::from_reader(plaintext.as_slice()).context("auc PIN state is malformed")?;
512 if state.version != envelope.version {
513 bail!("auc PIN state version does not match its authenticated envelope");
514 }
515 Ok(state)
516 }
517
518 fn save_pin_state(&mut self, state: &PinState) -> Result<()> {
519 if state.version < self.pin_version {
520 bail!("auc refused to roll PIN retry state backward");
521 }
522 let mut plaintext = Zeroizing::new(Vec::new());
523 ciborium::into_writer(state, &mut *plaintext)?;
524 let mut nonce = [0_u8; 24];
525 rand::rng().fill(&mut nonce);
526 let associated_data = pin_associated_data(state.version);
527 let envelope = PinEnvelope {
528 schema: PIN_SCHEMA,
529 version: state.version,
530 nonce,
531 ciphertext: self
532 .cipher()
533 .encrypt(
534 &XNonce::from(nonce),
535 Payload {
536 msg: &plaintext,
537 aad: &associated_data,
538 },
539 )
540 .map_err(|_| anyhow!("failed to encrypt auc PIN state"))?,
541 };
542 let mut bytes = Vec::new();
543 ciborium::into_writer(&envelope, &mut bytes)?;
544 if bytes.len() > MAX_PIN_BYTES as usize {
545 bail!("auc PIN state exceeds its safety limit");
546 }
547 replace_private_file(
548 &self.root.join(PIN_STATE_FILE),
549 &bytes,
550 ".auc-pin-",
551 self.uid,
552 self.gid,
553 )?;
554 self.pin_version = state.version;
555 Ok(())
556 }
557}
558
559#[derive(Deserialize, Serialize, Zeroize)]
560#[serde(deny_unknown_fields)]
561struct IdentityFile {
562 schema: u16,
563 vault_key: [u8; 32],
564 signing_key: [u8; 32],
565 device_id: [u8; 16],
566}
567
568#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
569#[serde(rename_all = "kebab-case")]
570enum PayloadDomain {
571 CredentialUpsert,
572 CredentialTombstone,
573}
574
575impl PayloadDomain {
576 fn as_byte(self) -> u8 {
577 match self {
578 Self::CredentialUpsert => 1,
579 Self::CredentialTombstone => 2,
580 }
581 }
582}
583
584#[derive(Deserialize, Serialize, Zeroize)]
585#[serde(rename_all = "kebab-case", tag = "kind", deny_unknown_fields)]
586enum EventPayload {
587 Upsert {
588 #[serde(with = "serde_bytes")]
589 credential: Vec<u8>,
590 },
591 Tombstone {
592 #[serde(with = "serde_bytes")]
593 credential_id: Vec<u8>,
594 },
595}
596
597impl EventPayload {
598 fn domain(&self) -> PayloadDomain {
599 match self {
600 Self::Upsert { .. } => PayloadDomain::CredentialUpsert,
601 Self::Tombstone { .. } => PayloadDomain::CredentialTombstone,
602 }
603 }
604}
605
606#[derive(Deserialize, Serialize)]
607#[serde(deny_unknown_fields)]
608struct EventEnvelope {
609 schema: u16,
610 sequence: u64,
611 event_id: [u8; 16],
612 previous_hash: [u8; 32],
613 signer: [u8; 32],
614 payload_domain: PayloadDomain,
615 nonce: [u8; 24],
616 #[serde(with = "serde_bytes")]
617 ciphertext: Vec<u8>,
618 #[serde(with = "serde_bytes")]
619 signature: Vec<u8>,
620}
621
622impl EventEnvelope {
623 fn validate(
624 &self,
625 expected_sequence: u64,
626 expected_previous_hash: [u8; 32],
627 local_signer: &VerifyingKey,
628 ) -> Result<()> {
629 if self.schema != EVENT_SCHEMA {
630 bail!("unsupported event schema {}", self.schema);
631 }
632 if self.sequence != expected_sequence || self.previous_hash != expected_previous_hash {
633 bail!("event sequence or hash chain is discontinuous");
634 }
635 if self.signer != local_signer.to_bytes() {
636 bail!("event is not signed by this local auc installation");
637 }
638 let signature: [u8; 64] = self
639 .signature
640 .as_slice()
641 .try_into()
642 .map_err(|_| anyhow!("event signature has the wrong length"))?;
643 local_signer
644 .verify(
645 &self.signature_message(),
646 &Signature::from_bytes(&signature),
647 )
648 .map_err(|_| anyhow!("event Ed25519 signature is invalid"))
649 }
650
651 fn associated_data(&self) -> Vec<u8> {
652 let mut bytes = Vec::with_capacity(EVENT_DOMAIN.len() + 2 + 8 + 16 + 32 + 32 + 1);
653 bytes.extend_from_slice(EVENT_DOMAIN);
654 bytes.extend_from_slice(&self.schema.to_be_bytes());
655 bytes.extend_from_slice(&self.sequence.to_be_bytes());
656 bytes.extend_from_slice(&self.event_id);
657 bytes.extend_from_slice(&self.previous_hash);
658 bytes.extend_from_slice(&self.signer);
659 bytes.push(self.payload_domain.as_byte());
660 bytes
661 }
662
663 fn signature_message(&self) -> Vec<u8> {
664 let mut bytes = Vec::with_capacity(
665 EVENT_SIGNATURE_DOMAIN.len()
666 + self.associated_data().len()
667 + 24
668 + 8
669 + self.ciphertext.len(),
670 );
671 bytes.extend_from_slice(EVENT_SIGNATURE_DOMAIN);
672 bytes.extend_from_slice(&self.associated_data());
673 bytes.extend_from_slice(&self.nonce);
674 bytes.extend_from_slice(&(self.ciphertext.len() as u64).to_be_bytes());
675 bytes.extend_from_slice(&self.ciphertext);
676 bytes
677 }
678}
679
680#[derive(Deserialize, Serialize)]
681#[serde(deny_unknown_fields)]
682struct PinEnvelope {
683 schema: u16,
684 version: u64,
685 nonce: [u8; 24],
686 #[serde(with = "serde_bytes")]
687 ciphertext: Vec<u8>,
688}
689
690fn load_or_create_identity(root: &Path, uid: u32, gid: u32) -> Result<IdentitySecrets> {
691 let path = root.join(IDENTITY_FILE);
692 let identity = Zeroizing::new(
693 match read_private_file(&path, uid, gid, MAX_IDENTITY_BYTES) {
694 Ok(bytes) => {
695 let bytes = Zeroizing::new(bytes);
696 ciborium::from_reader(bytes.as_slice())
697 .context("failed to decode auc vault identity")?
698 }
699 Err(error)
700 if error
701 .downcast_ref::<std::io::Error>()
702 .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
703 {
704 let mut vault_key = Zeroizing::new([0_u8; 32]);
705 let mut signing_key = Zeroizing::new([0_u8; 32]);
706 let mut device_id = [0_u8; 16];
707 rand::rng().fill(&mut vault_key[..]);
708 rand::rng().fill(&mut signing_key[..]);
709 rand::rng().fill(&mut device_id);
710 let identity = IdentityFile {
711 schema: IDENTITY_SCHEMA,
712 vault_key: *vault_key,
713 signing_key: *signing_key,
714 device_id,
715 };
716 let mut bytes = Zeroizing::new(Vec::new());
717 ciborium::into_writer(&identity, &mut *bytes)?;
718 persist_new_private_file(&path, &bytes, ".auc-identity-", uid, gid)?;
719 identity
720 }
721 Err(error) => return Err(error),
722 },
723 );
724 if identity.schema != IDENTITY_SCHEMA {
725 bail!("unsupported auc identity schema {}", identity.schema);
726 }
727 Ok(IdentitySecrets {
728 vault_key: Zeroizing::new(identity.vault_key),
729 signing_key: Zeroizing::new(identity.signing_key),
730 device_id: identity.device_id,
731 })
732}
733
734struct IdentitySecrets {
735 vault_key: Zeroizing<[u8; 32]>,
736 signing_key: Zeroizing<[u8; 32]>,
737 device_id: [u8; 16],
738}
739
740struct LockedSecret {
741 mapping: NonNull<libc::c_void>,
742 length: usize,
743}
744
745impl LockedSecret {
746 fn new(secret: Zeroizing<[u8; 32]>) -> Result<Self> {
747 use rustix::mm::{Advice, MapFlags, ProtFlags, madvise, mlock, mmap_anonymous, munmap};
748
749 let length = rustix::param::page_size();
750 let mapping = unsafe {
753 mmap_anonymous(
754 std::ptr::null_mut(),
755 length,
756 ProtFlags::READ | ProtFlags::WRITE,
757 MapFlags::PRIVATE,
758 )
759 }
760 .map_err(std::io::Error::from)
761 .context("failed to allocate locked auc secret memory")?;
762 let mapping = NonNull::new(mapping)
763 .ok_or_else(|| anyhow!("anonymous secret mapping unexpectedly returned null"))?;
764 if let Err(error) = unsafe { mlock(mapping.as_ptr(), length) } {
766 let _ = unsafe { munmap(mapping.as_ptr(), length) };
768 return Err(std::io::Error::from(error))
769 .context("failed to lock auc vault secrets into RAM");
770 }
771 if let Err(error) = unsafe { madvise(mapping.as_ptr(), length, Advice::LinuxDontDump) } {
773 let _ = unsafe { rustix::mm::munlock(mapping.as_ptr(), length) };
775 let _ = unsafe { munmap(mapping.as_ptr(), length) };
777 return Err(std::io::Error::from(error))
778 .context("failed to exclude auc vault secrets from core dumps");
779 }
780 unsafe {
783 std::ptr::copy_nonoverlapping(secret.as_ptr(), mapping.as_ptr().cast(), secret.len());
784 }
785 Ok(Self { mapping, length })
786 }
787}
788
789impl Deref for LockedSecret {
790 type Target = [u8; 32];
791
792 fn deref(&self) -> &Self::Target {
793 unsafe { &*self.mapping.as_ptr().cast() }
796 }
797}
798
799unsafe impl Send for LockedSecret {}
801unsafe impl Sync for LockedSecret {}
803
804impl Drop for LockedSecret {
805 fn drop(&mut self) {
806 unsafe {
809 for index in 0..32 {
810 std::ptr::write_volatile(self.mapping.as_ptr().cast::<u8>().add(index), 0);
811 }
812 }
813 compiler_fence(Ordering::SeqCst);
814 let _ = unsafe { rustix::mm::munlock(self.mapping.as_ptr(), self.length) };
816 let _ = unsafe { rustix::mm::munmap(self.mapping.as_ptr(), self.length) };
818 }
819}
820
821fn validate_credential(credential: &Credential) -> Result<()> {
822 validate_credential_id(&credential.id)?;
823 if credential.rp.id.is_empty() || credential.rp.id.len() > 253 {
824 bail!("credential RP ID is invalid");
825 }
826 if credential.user.id.is_empty() || credential.user.id.len() > 64 {
827 bail!("credential user ID is invalid");
828 }
829 if credential.sign_count != 0
830 || credential.alg != -7
831 || credential.backup_state != CredentialBackupState::Eligible
832 {
833 bail!("credential violates auc counter, algorithm, or backup-state policy");
834 }
835 Ok(())
836}
837
838fn validate_credential_id(credential_id: &[u8]) -> Result<()> {
839 if credential_id.is_empty() || credential_id.len() > 128 {
840 bail!("credential ID length is invalid");
841 }
842 Ok(())
843}
844
845fn event_filename(sequence: u64, event_id: &[u8; 16], event_hash: &[u8; 32]) -> String {
846 format!(
847 "{sequence:020}-{}-{}.cbor",
848 hex::encode(event_id),
849 hex::encode(event_hash)
850 )
851}
852
853fn pin_associated_data(version: u64) -> Vec<u8> {
854 let mut bytes = Vec::with_capacity(PIN_DOMAIN.len() + 2 + 8);
855 bytes.extend_from_slice(PIN_DOMAIN);
856 bytes.extend_from_slice(&PIN_SCHEMA.to_be_bytes());
857 bytes.extend_from_slice(&version.to_be_bytes());
858 bytes
859}
860
861fn ensure_secure_directory(path: &Path, uid: u32, gid: u32) -> Result<()> {
862 match fs::symlink_metadata(path) {
863 Ok(metadata) => validate_private_directory(path, &metadata, uid, gid)?,
864 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
865 let parent = path
866 .parent()
867 .ok_or_else(|| anyhow!("auc vault directory has no parent"))?;
868 let parent_metadata = fs::symlink_metadata(parent)?;
869 validate_directory(parent, &parent_metadata, uid, gid)?;
870 fs::DirBuilder::new().mode(0o700).create(path)?;
871 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
872 sync_directory(parent)?;
873 }
874 Err(error) => return Err(error.into()),
875 }
876 validate_private_directory(path, &fs::symlink_metadata(path)?, uid, gid)
877}
878
879fn validate_directory(path: &Path, metadata: &fs::Metadata, uid: u32, gid: u32) -> Result<()> {
880 if !metadata.file_type().is_dir() || metadata.uid() != uid || metadata.gid() != gid {
881 bail!(
882 "auc vault path is not a correctly owned real directory: {}",
883 path.display()
884 );
885 }
886 Ok(())
887}
888
889fn validate_private_directory(
890 path: &Path,
891 metadata: &fs::Metadata,
892 uid: u32,
893 gid: u32,
894) -> Result<()> {
895 validate_directory(path, metadata, uid, gid)?;
896 if metadata.mode() & 0o7777 != 0o700 {
897 bail!("auc vault directory mode is not 0700: {}", path.display());
898 }
899 Ok(())
900}
901
902fn read_private_file(path: &Path, uid: u32, gid: u32, limit: u64) -> Result<Vec<u8>> {
903 let mut options = OpenOptions::new();
904 options
905 .read(true)
906 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
907 let file = options.open(path)?;
908 let metadata = file.metadata()?;
909 if !metadata.is_file()
910 || metadata.uid() != uid
911 || metadata.gid() != gid
912 || metadata.mode() & 0o7777 != 0o600
913 || metadata.len() > limit
914 {
915 bail!(
916 "auc private file failed ownership, mode, type, or size validation: {}",
917 path.display()
918 );
919 }
920 let mut bytes = Vec::with_capacity(metadata.len() as usize);
921 use std::io::Read as _;
922 file.take(limit + 1).read_to_end(&mut bytes)?;
923 if bytes.len() as u64 > limit {
924 bail!(
925 "auc private file exceeds its size limit: {}",
926 path.display()
927 );
928 }
929 Ok(bytes)
930}
931
932fn persist_new_private_file(
933 destination: &Path,
934 bytes: &[u8],
935 prefix: &str,
936 uid: u32,
937 gid: u32,
938) -> Result<()> {
939 let parent = destination
940 .parent()
941 .ok_or_else(|| anyhow!("auc private file has no parent"))?;
942 validate_private_directory(parent, &fs::symlink_metadata(parent)?, uid, gid)?;
943 let mut temporary = Builder::new().prefix(prefix).tempfile_in(parent)?;
944 temporary
945 .as_file()
946 .set_permissions(fs::Permissions::from_mode(0o600))?;
947 temporary.write_all(bytes)?;
948 temporary.as_file().sync_all()?;
949 temporary
950 .persist_noclobber(destination)
951 .map_err(|error| error.error)?;
952 sync_directory(parent)
953}
954
955fn replace_private_file(
956 destination: &Path,
957 bytes: &[u8],
958 prefix: &str,
959 uid: u32,
960 gid: u32,
961) -> Result<()> {
962 let parent = destination
963 .parent()
964 .ok_or_else(|| anyhow!("auc private file has no parent"))?;
965 validate_private_directory(parent, &fs::symlink_metadata(parent)?, uid, gid)?;
966 if destination.exists() {
967 read_private_file(destination, uid, gid, MAX_PIN_BYTES)?;
968 }
969 let mut temporary = Builder::new().prefix(prefix).tempfile_in(parent)?;
970 temporary
971 .as_file()
972 .set_permissions(fs::Permissions::from_mode(0o600))?;
973 temporary.write_all(bytes)?;
974 temporary.as_file().sync_all()?;
975 temporary
976 .persist(destination)
977 .map_err(|error| error.error)?;
978 sync_directory(parent)
979}
980
981fn remove_uncommitted_files(path: &Path, prefix: &str, uid: u32, gid: u32) -> Result<()> {
982 for entry in fs::read_dir(path)? {
983 let entry = entry?;
984 let name = entry.file_name();
985 if name.as_bytes().starts_with(prefix.as_bytes()) {
986 let metadata = fs::symlink_metadata(entry.path())?;
987 if !metadata.is_file()
988 || metadata.uid() != uid
989 || metadata.gid() != gid
990 || metadata.mode() & 0o7777 != 0o600
991 {
992 bail!(
993 "unsafe uncommitted file in auc vault: {}",
994 entry.path().display()
995 );
996 }
997 fs::remove_file(entry.path())?;
998 }
999 }
1000 Ok(())
1001}
1002
1003fn sync_directory(path: &Path) -> Result<()> {
1004 File::open(path)?.sync_all().map_err(Into::into)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010 use soft_fido2::{Extensions, RelyingParty, User};
1011
1012 fn credential(id: u8) -> Credential {
1013 use soft_fido2::SoftwareCredentialKeyProvider;
1014
1015 let provider = SoftwareCredentialKeyProvider;
1016 let generated = soft_fido2::CredentialKeyProvider::generate(&provider, -7).unwrap();
1017 Credential {
1018 id: vec![id; 32],
1019 rp: RelyingParty::new("example.test".to_string()),
1020 user: User::new(vec![id]),
1021 sign_count: 0,
1022 alg: -7,
1023 key: generated.key,
1024 created: 1,
1025 discoverable: true,
1026 backup_state: CredentialBackupState::Eligible,
1027 extensions: Extensions::default(),
1028 }
1029 }
1030
1031 fn append_credential(vault: &Vault, credential: &Credential) {
1032 vault
1033 .lock()
1034 .unwrap()
1035 .append(EventPayload::Upsert {
1036 credential: credential.to_bytes().unwrap(),
1037 })
1038 .unwrap();
1039 }
1040
1041 #[test]
1042 fn event_store_round_trips_and_tombstones_are_permanent() {
1043 let directory = tempfile::tempdir().unwrap();
1044 let root = directory.path().join("vault");
1045 let vault = Vault::open_for_test(&root).unwrap();
1046 append_credential(&vault, &credential(1));
1047 assert_eq!(vault.credential_count().unwrap(), 1);
1048 assert!(vault.delete_credential(&[1; 32]).unwrap());
1049 assert_eq!(vault.credential_count().unwrap(), 0);
1050 drop(vault);
1051
1052 let vault = Vault::open_for_test(&root).unwrap();
1053 assert_eq!(vault.credential_count().unwrap(), 0);
1054 let error = vault
1055 .lock()
1056 .unwrap()
1057 .append(EventPayload::Upsert {
1058 credential: credential(1).to_bytes().unwrap(),
1059 })
1060 .unwrap_err();
1061 assert!(error.to_string().contains("tombstone"));
1062 }
1063
1064 #[test]
1065 fn tampered_event_fails_closed_with_its_filename() {
1066 let directory = tempfile::tempdir().unwrap();
1067 let root = directory.path().join("vault");
1068 let vault = Vault::open_for_test(&root).unwrap();
1069 append_credential(&vault, &credential(2));
1070 drop(vault);
1071 let event = fs::read_dir(root.join(EVENTS_DIRECTORY))
1072 .unwrap()
1073 .next()
1074 .unwrap()
1075 .unwrap()
1076 .path();
1077 let mut bytes = fs::read(&event).unwrap();
1078 let index = bytes.len() - 1;
1079 bytes[index] ^= 1;
1080 fs::write(&event, bytes).unwrap();
1081
1082 let error = Vault::open_for_test(&root).err().unwrap();
1083 assert!(
1084 error
1085 .to_string()
1086 .contains(event.file_name().unwrap().to_str().unwrap())
1087 );
1088 }
1089
1090 #[test]
1091 fn pin_state_survives_restart_and_cannot_roll_back() {
1092 let directory = tempfile::tempdir().unwrap();
1093 let root = directory.path().join("vault");
1094 let vault = Vault::open_for_test(&root).unwrap();
1095 let mut state = PinState {
1096 retries: 3,
1097 uv_retries: 1,
1098 version: 7,
1099 force_pin_change: true,
1100 ..PinState::default()
1101 };
1102 vault.lock().unwrap().save_pin_state(&state).unwrap();
1103 drop(vault);
1104
1105 let vault = Vault::open_for_test(&root).unwrap();
1106 let loaded = vault.lock().unwrap().load_pin_state().unwrap();
1107 assert_eq!(loaded.retries, 3);
1108 assert_eq!(loaded.uv_retries, 1);
1109 assert_eq!(loaded.version, 7);
1110 assert!(loaded.force_pin_change);
1111
1112 state.version = 6;
1113 let error = vault.lock().unwrap().save_pin_state(&state).unwrap_err();
1114 assert!(error.to_string().contains("backward"));
1115 }
1116
1117 #[test]
1118 fn tampered_pin_state_fails_closed() {
1119 let directory = tempfile::tempdir().unwrap();
1120 let root = directory.path().join("vault");
1121 let vault = Vault::open_for_test(&root).unwrap();
1122 let state = PinState {
1123 version: 1,
1124 ..PinState::default()
1125 };
1126 vault.lock().unwrap().save_pin_state(&state).unwrap();
1127 drop(vault);
1128
1129 let path = root.join(PIN_STATE_FILE);
1130 let mut bytes = fs::read(&path).unwrap();
1131 let index = bytes.len() - 1;
1132 bytes[index] ^= 1;
1133 fs::write(path, bytes).unwrap();
1134
1135 let error = Vault::open_for_test(&root).err().unwrap();
1136 assert!(format!("{error:#}").contains("PIN state authentication failed"));
1137 }
1138
1139 #[test]
1140 fn insecure_vault_modes_fail_closed_without_being_repaired() {
1141 let directory = tempfile::tempdir().unwrap();
1142 let root = directory.path().join("vault");
1143 drop(Vault::open_for_test(&root).unwrap());
1144
1145 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
1146 assert!(Vault::open_for_test(&root).is_err());
1147 assert_eq!(fs::symlink_metadata(&root).unwrap().mode() & 0o7777, 0o755);
1148
1149 fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
1150 let identity = root.join(IDENTITY_FILE);
1151 fs::set_permissions(&identity, fs::Permissions::from_mode(0o644)).unwrap();
1152 assert!(Vault::open_for_test(&root).is_err());
1153 }
1154}