Skip to main content

auc_tool/
vault.rs

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        // SAFETY: this creates one private anonymous page. Every failure path unmaps it, and the
751        // successful path gives sole ownership to LockedSecret until Drop.
752        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        // SAFETY: mapping owns a writable page of `length` bytes for the full operation.
765        if let Err(error) = unsafe { mlock(mapping.as_ptr(), length) } {
766            // SAFETY: no reference or value has yet been created in this mapping.
767            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        // SAFETY: the mapping is page-aligned, private, and remains valid for `length` bytes.
772        if let Err(error) = unsafe { madvise(mapping.as_ptr(), length, Advice::LinuxDontDump) } {
773            // SAFETY: the page contains no initialized secret yet.
774            let _ = unsafe { rustix::mm::munlock(mapping.as_ptr(), length) };
775            // SAFETY: no reference or initialized value remains in this mapping.
776            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        // SAFETY: the mapping is writable and at least one page, while the source is a live
781        // 32-byte array. The regions cannot overlap.
782        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        // SAFETY: LockedSecret initialized the first 32 bytes and keeps the mapping readable and
794        // uniquely owned until Drop. No method mutates it after construction.
795        unsafe { &*self.mapping.as_ptr().cast() }
796    }
797}
798
799// SAFETY: LockedSecret owns an independent immutable mapping; shared access exposes only &[u8; 32].
800unsafe impl Send for LockedSecret {}
801// SAFETY: LockedSecret owns an independent immutable mapping; shared access exposes only &[u8; 32].
802unsafe impl Sync for LockedSecret {}
803
804impl Drop for LockedSecret {
805    fn drop(&mut self) {
806        // SAFETY: this is the final access to our uniquely owned writable mapping. Volatile writes
807        // plus the compiler fence prevent removal or reordering of the erasure before munlock.
808        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        // SAFETY: all references are gone and both calls use the original mapping and length.
815        let _ = unsafe { rustix::mm::munlock(self.mapping.as_ptr(), self.length) };
816        // SAFETY: the mapping is no longer accessed after this call.
817        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}