diff --git a/crates/hyli-verifiers/src/lib.rs b/crates/hyli-verifiers/src/lib.rs index 3548d1557..d0cf7792d 100644 --- a/crates/hyli-verifiers/src/lib.rs +++ b/crates/hyli-verifiers/src/lib.rs @@ -285,10 +285,7 @@ pub mod sp1_4 { mod tests { use std::{fs::File, io::Read}; - use hyli_model::{ - Blob, BlobData, BlobIndex, HyliOutput, Identity, IndexedBlobs, ProgramId, ProofData, - StateCommitment, TxHash, - }; + use hyli_model::{BlobIndex, Identity, ProgramId, ProofData, StateCommitment, TxHash}; use super::noir::verify as noir_proof_verifier; @@ -303,74 +300,94 @@ mod tests { /* For this test, the proof/vk and the output are obtained running this simple Noir code ``` - fn main( - version: pub u32, - initial_state_len: pub u32, - initial_state: pub [u8; 4], - next_state_len: pub u32, - next_state: pub [u8; 4], - identity_len: pub u8, - identity: pub str<56>, - tx_hash_len: pub u32, - tx_hash: pub [u8; 0], - index: pub u32, - blobs_len: pub u32, - blobs: pub [Field; 10], - success: pub bool - ) {} - ``` - With the values: - ``` - version = 1 - blobs = [3, 1, 1, 2, 1, 1, 2, 1, 1, 0] - initial_state_len = 4 - initial_state = [0, 0, 0, 0] - next_state_len = 4 - next_state = [0, 0, 0, 0] - identity_len = 56 - identity = "3f368bf90c71946fc7b0cde9161ace42985d235f@ecdsa_secp256r1" - tx_hash_len = 0 - tx_hash = [] - blobs_len = 9 - index = 0 - success = 1 + struct BlobInput { + index: u32, + contract_name_len: u32, + contract_name: str, + data_capacity: u32, + data_len: u32, + data: [u8; DATA_MAX], + } + struct HyliOutput< + let INITIAL_STATE_MAX: u32, + let NEXT_STATE_MAX: u32, + let IDENTITY_MAX: u32, + let BLOBS_MAX: u32, + let BLOB_NAME_MAX: u32, + let BLOB_DATA_MAX: u32, + let PROGRAM_OUTPUT_MAX: u32 + > { + version: u32, + initial_state_len: u32, + initial_state_max: u32, + initial_state: [u8; INITIAL_STATE_MAX], + next_state_len: u32, + next_state_max: u32, + next_state: [u8; NEXT_STATE_MAX], + identity_len: u32, + identity_max: u32, + identity: str, + index: u32, + blob_count: u32, + blob_slots: u32, + blob_name_max: u32, + blob_data_max: u32, + blobs: [BlobInput; BLOBS_MAX], + tx_blob_count: u32, + tx_hash: [u8; 32], + success: bool, + program_outputs_max: u32, + program_outputs_len: u32, + program_outputs: [u8; PROGRAM_OUTPUT_MAX], + } + + fn main(hyli_output: pub HyliOutput<4, 4, 56, 2, 8, 4, 2>) { + assert(hyli_output.version == 2); + assert(hyli_output.initial_state_len == 4); + assert(hyli_output.initial_state_max == INITIAL_STATE_MAX); + assert(hyli_output.next_state_len == 4); + assert(hyli_output.next_state_max == NEXT_STATE_MAX); + assert(hyli_output.identity_len == 56); + assert(hyli_output.identity_max == IDENTITY_MAX); + assert(hyli_output.blob_slots == BLOB_SLOTS); + assert(hyli_output.blob_name_max == BLOB_NAME_MAX); + assert(hyli_output.blob_data_max == BLOB_DATA_MAX); + assert(hyli_output.blob_count <= hyli_output.blob_slots); + assert(hyli_output.tx_hash_len == 32); + assert(hyli_output.program_outputs_max == PROGRAM_OUTPUT_MAX); + assert(hyli_output.program_outputs_len <= hyli_output.program_outputs_max); + assert(hyli_output.program_outputs_len == 2); + assert(hyli_output.success); + } ``` */ #[ignore = "manual test"] #[test_log::test] - fn test_noir_proof_verifier() { - let noir_proof = load_file_as_bytes("./tests/proofs/webauthn.noir.proof"); - let image_id = load_file_as_bytes("./tests/proofs/webauthn.noir.vk"); + fn test_noir_proof_verifier_v2() { + let mut noir_proof = load_file_as_bytes("../../tests/proofs/parserv2.noir.public_inputs"); + noir_proof.extend(load_file_as_bytes("../../tests/proofs/parserv2.noir.proof")); + let image_id = load_file_as_bytes("../../tests/proofs/parserv2.noir.vk"); let result = noir_proof_verifier(&ProofData(noir_proof), &ProgramId(image_id)); match result { Ok(outputs) => { - assert_eq!( - outputs, - vec![HyliOutput { - version: 1, - initial_state: StateCommitment(vec![0, 0, 0, 0]), - next_state: StateCommitment(vec![0, 0, 0, 0]), - identity: Identity( - "3f368bf90c71946fc7b0cde9161ace42985d235f@ecdsa_secp256r1".to_owned() - ), - index: BlobIndex(0), - blobs: IndexedBlobs(vec![( - BlobIndex(0), - Blob { - contract_name: "webauthn".into(), - data: BlobData(vec![3, 1, 1, 2, 1, 1, 2, 1, 1, 0]) - } - )]), - tx_blob_count: 1, - success: true, - tx_hash: TxHash::default(), // TODO - state_reads: vec![], - tx_ctx: None, - onchain_effects: vec![], - program_outputs: vec![] - }] - ); + assert_eq!(outputs.len(), 1); + let output = &outputs[0]; + assert_eq!(output.version, 2); + assert_eq!(output.initial_state, StateCommitment(vec![0, 0, 0, 0])); + assert_eq!(output.next_state, StateCommitment(vec![0, 0, 0, 0])); + assert_eq!(output.identity, Identity("transfer@hyli_utxo".to_owned())); + assert_eq!(output.index, BlobIndex(1)); + assert_eq!(output.tx_blob_count, 2); + assert!(output.success); + assert_eq!(output.tx_hash, TxHash(vec![0; 32])); + assert!(output.program_outputs.is_empty()); + + assert_eq!(output.blobs.0.len(), 1); + let (blob_index, blob) = &output.blobs.0[0]; + assert_eq!(*blob_index, BlobIndex(1)); + assert_eq!(blob.contract_name.0, "hyli_utxo"); + assert!(!blob.data.0.is_empty()); } Err(e) => panic!("Noir verification failed: {e:?}"), } diff --git a/crates/hyli-verifiers/src/noir_utils.rs b/crates/hyli-verifiers/src/noir_utils.rs index 2b7bc706e..5d4177998 100644 --- a/crates/hyli-verifiers/src/noir_utils.rs +++ b/crates/hyli-verifiers/src/noir_utils.rs @@ -1,5 +1,7 @@ -use anyhow::{Context, Error}; -use hyli_model::{Blob, BlobIndex, HyliOutput, IndexedBlobs, StateCommitment, TxHash}; +use std::collections::VecDeque; + +use anyhow::{anyhow, bail, Context, Error}; +use hyli_model::{Blob, BlobData, BlobIndex, HyliOutput, IndexedBlobs, StateCommitment, TxHash}; use tracing::debug; /// Extracts the public inputs from the output of `reconstruct_honk_proof`. @@ -40,19 +42,27 @@ pub fn deflatten_fields(flattened_fields: &[u8]) -> Vec { } pub fn parse_noir_output(public_inputs: &[u8]) -> Result { - let mut vector = deflatten_fields(public_inputs); - let version = u32::from_str_radix(&vector.remove(0), 16)?; + let mut fields: VecDeque = deflatten_fields(public_inputs).into(); + let version = parse_u32(&mut fields)?; debug!("Parsed version: {}", version); - let initial_state = parse_array(&mut vector)?; - let next_state = parse_array(&mut vector)?; - let identity = parse_string_with_len(&mut vector)?; - let tx_hash = parse_sized_string(&mut vector, 64)?; - let index = u32::from_str_radix(&vector.remove(0), 16)?; + match version { + 1 => parse_noir_output_v1(version, &mut fields), + 2 => parse_noir_output_v2(version, &mut fields), + _ => Err(anyhow::anyhow!("Unsupported version: {version}")), + } +} + +fn parse_noir_output_v1(version: u32, fields: &mut VecDeque) -> Result { + let initial_state = parse_array_v1(fields)?; + let next_state = parse_array_v1(fields)?; + let identity = parse_string_with_len_v1(fields)?; + let tx_hash = parse_sized_string_v1(fields, 64)?; + let index = parse_u32(fields)?; debug!("Parsed index: {}", index); - let blobs = parse_blobs(&mut vector)?; - let tx_blob_count = usize::from_str_radix(&vector.remove(0), 16)?; + let blobs = parse_blobs_v1(fields)?; + let tx_blob_count = parse_u32(fields)? as usize; debug!("Parsed tx_blob_count: {}", tx_blob_count); - let success = u32::from_str_radix(&vector.remove(0), 16)? == 1; + let success = parse_u32(fields)? == 1; debug!("Parsed success: {}", success); Ok(HyliOutput { @@ -68,18 +78,73 @@ pub fn parse_noir_output(public_inputs: &[u8]) -> Result { success, state_reads: vec![], onchain_effects: vec![], - // Parse the remained as an array, if any - program_outputs: match vector.len() { - 0 => vec![], - _ => parse_array(&mut vector).unwrap_or_default(), + // Keep v1 behaviour: if extra fields exist, try parsing once as program_outputs. + program_outputs: match fields.is_empty() { + true => vec![], + false => parse_array_v1(fields).unwrap_or_default(), }, }) } -fn parse_sized_string(vector: &mut Vec, length: usize) -> Result { +fn parse_noir_output_v2(version: u32, fields: &mut VecDeque) -> Result { + let initial_state = parse_bounded_bytes(fields, "initial_state")?; + let next_state = parse_bounded_bytes(fields, "next_state")?; + let identity = parse_bounded_utf8(fields, "identity")?; + let index = parse_u32(fields)? as usize; + + let blob_count = parse_u32(fields)? as usize; + let blob_slots = parse_u32(fields)? as usize; + let blob_name_max = parse_u32(fields)? as usize; + let blob_data_max = parse_u32(fields)? as usize; + if blob_count > blob_slots { + bail!("blob_count ({blob_count}) exceeds blob_slots ({blob_slots})"); + } + let blobs = parse_blob_slots_v2(fields, blob_count, blob_slots, blob_name_max, blob_data_max)?; + + let tx_blob_count = parse_u32(fields)? as usize; + if blob_count > tx_blob_count { + bail!("blob_count ({blob_count}) exceeds tx_blob_count ({tx_blob_count})"); + } + + let tx_hash = parse_fixed_bytes(fields, "tx_hash", 32)?; + + let success = parse_bool(fields)?; + let program_outputs_max = parse_u32(fields)? as usize; + let program_outputs_len = parse_u32(fields)? as usize; + let mut program_outputs = parse_fixed_bytes(fields, "program_outputs", program_outputs_max)?; + if program_outputs_len > program_outputs.len() { + bail!( + "program_outputs_len ({program_outputs_len}) exceeds program_outputs_max ({})", + program_outputs.len() + ); + } + program_outputs.truncate(program_outputs_len); + + if !fields.is_empty() { + debug!("Ignoring trailing v2 fields: {}", fields.len()); + } + + Ok(HyliOutput { + version, + initial_state: StateCommitment(initial_state), + next_state: StateCommitment(next_state), + identity: identity.into(), + index: BlobIndex(index), + blobs, + tx_blob_count, + tx_hash: TxHash(tx_hash), + success, + state_reads: vec![], + tx_ctx: None, + onchain_effects: vec![], + program_outputs, + }) +} + +fn parse_sized_string_v1(vector: &mut VecDeque, length: usize) -> Result { let mut resp = String::with_capacity(length); for _ in 0..length { - let code = u32::from_str_radix(&vector.remove(0), 16)?; + let code = parse_u32(vector)?; let ch = std::char::from_u32(code) .ok_or_else(|| anyhow::anyhow!("Invalid char code: {}", code))?; resp.push(ch); @@ -90,52 +155,52 @@ fn parse_sized_string(vector: &mut Vec, length: usize) -> Result) -> Result { - let length = usize::from_str_radix(&vector.remove(0), 16)?; +fn parse_string_with_len_v1(vector: &mut VecDeque) -> Result { + let length = parse_u32(vector)? as usize; if length > 256 { return Err(anyhow::anyhow!( "Invalid contract name length {length}. Max is 256." )); } - let mut field = parse_sized_string(vector, 256)?; + let mut field = parse_sized_string_v1(vector, 256)?; field.truncate(length); debug!("Parsed string: {}", field); Ok(field) } -fn parse_array(vector: &mut Vec) -> Result, Error> { - let length = usize::from_str_radix(&vector.remove(0), 16)?; +fn parse_array_v1(vector: &mut VecDeque) -> Result, Error> { + let length = parse_u32(vector)? as usize; let mut resp = Vec::with_capacity(length); for _ in 0..length { - let num = u8::from_str_radix(&vector.remove(0), 16)?; + let num = parse_u8(vector)?; resp.push(num); } debug!("Parsed array of len: {}", length); Ok(resp) } -fn parse_blobs(blob_data: &mut Vec) -> Result { - let blob_number = usize::from_str_radix(&blob_data.remove(0), 16)?; +fn parse_blobs_v1(blob_data: &mut VecDeque) -> Result { + let blob_number = parse_u32(blob_data)? as usize; let mut blobs = IndexedBlobs::default(); debug!("blob_number: {}", blob_number); for _ in 0..blob_number { - let index = usize::from_str_radix(&blob_data.remove(0), 16)?; + let index = parse_u32(blob_data)? as usize; debug!("blob index: {}", index); - let contract_name = parse_string_with_len(blob_data)?; + let contract_name = parse_string_with_len_v1(blob_data)?; - let blob_capacity = usize::from_str_radix(&blob_data.remove(0), 16)?; - let blob_len = usize::from_str_radix(&blob_data.remove(0), 16)?; + let blob_capacity = parse_u32(blob_data)? as usize; + let blob_len = parse_u32(blob_data)? as usize; debug!("blob len: {} (capacity: {})", blob_len, blob_capacity); let mut blob = Vec::with_capacity(blob_capacity); for i in 0..blob_capacity { - let v = &blob_data.remove(0); + let v = pop_field(blob_data)?; blob.push( - u8::from_str_radix(v, 16) + u8::from_str_radix(&v, 16) .context(format!("Failed to parse blob data at {i}/{blob_capacity}"))?, ); } @@ -155,3 +220,238 @@ fn parse_blobs(blob_data: &mut Vec) -> Result { Ok(blobs) } + +fn parse_blob_slots_v2( + fields: &mut VecDeque, + blob_count: usize, + blob_slots: usize, + blob_name_max: usize, + blob_data_max: usize, +) -> Result { + let mut blobs = IndexedBlobs::default(); + for slot_index in 0..blob_slots { + let index = parse_u32(fields)? as usize; + let contract_name_len = parse_u32(fields)? as usize; + let mut contract_name = parse_string(fields, "blob.contract_name", blob_name_max)?; + if contract_name_len > contract_name.len() { + bail!( + "blob.contract_name_len ({contract_name_len}) exceeds blob_name_max ({})", + contract_name.len() + ); + } + contract_name.truncate(contract_name_len); + + let data_len = parse_u32(fields)? as usize; + let mut data = parse_fixed_bytes(fields, "blob.data", blob_data_max)?; + if data_len > data.len() { + bail!( + "blob.data_len ({data_len}) exceeds blob_data_max ({})", + data.len() + ); + } + data.truncate(data_len); + if slot_index < blob_count { + blobs.push(( + BlobIndex(index), + Blob { + contract_name: contract_name.into(), + data: BlobData(data), + }, + )); + } + } + Ok(blobs) +} + +fn parse_string( + fields: &mut VecDeque, + label: &str, + fixed_len: usize, +) -> Result { + let bytes = parse_fixed_bytes(fields, label, fixed_len)?; + String::from_utf8(bytes).context(format!("Invalid UTF-8 for {label}")) +} + +fn parse_bounded_bytes(fields: &mut VecDeque, label: &str) -> Result, Error> { + let length = parse_u32(fields)? as usize; + let max = parse_u32(fields)? as usize; + let mut out = parse_fixed_bytes(fields, label, max)?; + if length > out.len() { + bail!("{label}_len ({length}) exceeds {label}_max ({})", out.len()); + } + out.truncate(length); + debug!("Parsed {label} bytes len: {} (max: {})", length, max); + Ok(out) +} + +fn parse_bounded_utf8(fields: &mut VecDeque, label: &str) -> Result { + let bytes = parse_bounded_bytes(fields, label)?; + String::from_utf8(bytes).context(format!("Invalid UTF-8 for {label}")) +} + +fn parse_fixed_bytes( + fields: &mut VecDeque, + label: &str, + fixed_len: usize, +) -> Result, Error> { + let mut out = Vec::with_capacity(fixed_len); + for _ in 0..fixed_len { + out.push(parse_u8(fields)?); + } + debug!("Parsed fixed {label} bytes len: {}", fixed_len); + Ok(out) +} + +fn parse_bool(fields: &mut VecDeque) -> Result { + let raw = parse_u8(fields)?; + match raw { + 0 => Ok(false), + 1 => Ok(true), + _ => bail!("Invalid bool value: {}", raw), + } +} + +fn parse_u8(fields: &mut VecDeque) -> Result { + let raw = pop_field(fields)?; + u8::from_str_radix(&raw, 16).context("Failed to parse u8 from field") +} + +fn parse_u32(fields: &mut VecDeque) -> Result { + let raw = pop_field(fields)?; + u32::from_str_radix(&raw, 16).context("Failed to parse u32 from field") +} + +fn pop_field(fields: &mut VecDeque) -> Result { + fields + .pop_front() + .ok_or_else(|| anyhow!("Unexpected end of Noir public inputs")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_field(fields: &mut Vec, value: u128) { + let mut slot = [0u8; 32]; + let bytes = value.to_be_bytes(); + slot[16..].copy_from_slice(&bytes); + fields.extend_from_slice(&slot); + } + + fn push_fixed_string(fields: &mut Vec, fixed_len: usize, s: &str) { + let bytes = s.as_bytes(); + for &b in bytes { + push_field(fields, b as u128); + } + for _ in bytes.len()..fixed_len { + push_field(fields, 0); + } + } + + fn push_fixed_bytes(fields: &mut Vec, fixed_len: usize, bytes: &[u8]) { + for &b in bytes { + push_field(fields, b as u128); + } + for _ in bytes.len()..fixed_len { + push_field(fields, 0); + } + } + + fn build_minimal_v2_public_inputs() -> Vec { + let initial_state_max = 4usize; + let next_state_max = 4usize; + let identity_max = 56usize; + let blob_name_max = 8usize; + let blob_data_max = 4usize; + let program_outputs_max = 4usize; + let mut out = Vec::new(); + push_field(&mut out, 2); // version + push_field(&mut out, 4); // initial_state_len + push_field(&mut out, initial_state_max as u128); // initial_state_max + push_fixed_bytes(&mut out, initial_state_max, &[0, 0, 0, 0]); // initial_state + push_field(&mut out, 4); // next_state_len + push_field(&mut out, next_state_max as u128); // next_state_max + push_fixed_bytes(&mut out, next_state_max, &[1, 2, 3, 4]); // next_state + let identity = "id@ecdsa_secp256r1"; + push_field(&mut out, identity.len() as u128); // identity_len + push_field(&mut out, identity_max as u128); // identity_max + push_fixed_string(&mut out, identity_max, identity); + push_field(&mut out, 0); // index + push_field(&mut out, 0); // blob_count + push_field(&mut out, 1); // blob_slots + push_field(&mut out, blob_name_max as u128); // blob_name_max + push_field(&mut out, blob_data_max as u128); // blob_data_max + push_field(&mut out, 0); // blob[0].index + push_field(&mut out, 0); // blob[0].contract_name_len + push_fixed_bytes(&mut out, blob_name_max, b""); // blob[0].contract_name + push_field(&mut out, 0); // blob[0].data_len + push_fixed_bytes(&mut out, blob_data_max, b""); // blob[0].data + push_field(&mut out, 0); // tx_blob_count + push_fixed_bytes(&mut out, 32, &[0u8; 32]); // tx_hash [u8;32] + push_field(&mut out, 1); // success + push_field(&mut out, program_outputs_max as u128); // program_outputs_max + push_field(&mut out, 2); // program_outputs_len + push_fixed_bytes(&mut out, program_outputs_max, b"ok"); // program_outputs + out + } + + #[test] + fn parses_v2_minimal_output() { + let inputs = build_minimal_v2_public_inputs(); + let parsed = parse_noir_output(&inputs).expect("parse v2 minimal"); + assert_eq!(parsed.version, 2); + assert_eq!(parsed.initial_state.0, vec![0, 0, 0, 0]); + assert_eq!(parsed.next_state.0, vec![1, 2, 3, 4]); + assert_eq!(parsed.identity.0, "id@ecdsa_secp256r1"); + assert_eq!(parsed.tx_hash.0.len(), 32); + assert!(parsed.success); + assert_eq!(parsed.program_outputs, b"ok"); + } + + #[test] + fn decodes_v2_tx_hash_as_fixed_32_bytes() { + let mut inputs = Vec::new(); + push_field(&mut inputs, 2); // version + push_field(&mut inputs, 0); // initial_state_len + push_field(&mut inputs, 4); // initial_state_max + push_fixed_bytes(&mut inputs, 4, &[]); // initial_state + push_field(&mut inputs, 0); // next_state_len + push_field(&mut inputs, 4); // next_state_max + push_fixed_bytes(&mut inputs, 4, &[]); // next_state + push_field(&mut inputs, 1); // identity_len + push_field(&mut inputs, 56); // identity_max + push_fixed_string(&mut inputs, 56, "x"); + push_field(&mut inputs, 0); // index + push_field(&mut inputs, 0); // blob_count + push_field(&mut inputs, 1); // blob_slots + push_field(&mut inputs, 8); // blob_name_max + push_field(&mut inputs, 4); // blob_data_max + push_field(&mut inputs, 0); // blob[0].index + push_field(&mut inputs, 0); // blob[0].contract_name_len + push_fixed_bytes(&mut inputs, 8, b""); // blob[0].contract_name + push_field(&mut inputs, 0); // blob[0].data_len + push_fixed_bytes(&mut inputs, 4, b""); // blob[0].data + push_field(&mut inputs, 0); // tx_blob_count + let mut tx_hash = [0u8; 32]; + tx_hash[0] = 0xAB; + tx_hash[31] = 0xCD; + push_fixed_bytes(&mut inputs, 32, &tx_hash); // tx_hash payload + push_field(&mut inputs, 0); // success + push_field(&mut inputs, 4); // outputs_max + push_field(&mut inputs, 0); // outputs_len + push_fixed_bytes(&mut inputs, 4, &[]); // outputs + + let parsed = parse_noir_output(&inputs).expect("must parse"); + assert_eq!(parsed.tx_hash.0.len(), 32); + assert_eq!(parsed.tx_hash.0.first().copied(), Some(0xAB)); + assert_eq!(parsed.tx_hash.0.last().copied(), Some(0xCD)); + } + + #[test] + fn tolerates_v2_trailing_fields() { + let mut inputs = build_minimal_v2_public_inputs(); + push_field(&mut inputs, 99); // trailing unexpected field + let parsed = parse_noir_output(&inputs).expect("must parse with trailing fields"); + assert_eq!(parsed.version, 2); + } +} diff --git a/tests/proofs/parserv2.noir.proof b/tests/proofs/parserv2.noir.proof new file mode 100644 index 000000000..3c7784a86 Binary files /dev/null and b/tests/proofs/parserv2.noir.proof differ diff --git a/tests/proofs/parserv2.noir.public_inputs b/tests/proofs/parserv2.noir.public_inputs new file mode 100644 index 000000000..bfd8b27a5 Binary files /dev/null and b/tests/proofs/parserv2.noir.public_inputs differ diff --git a/tests/proofs/parserv2.noir.vk b/tests/proofs/parserv2.noir.vk new file mode 100644 index 000000000..5ee3c66d2 Binary files /dev/null and b/tests/proofs/parserv2.noir.vk differ diff --git a/tests/proofs/webauthn.noir.proof b/tests/proofs/webauthn.noir.proof deleted file mode 100644 index 0d558f4cd..000000000 Binary files a/tests/proofs/webauthn.noir.proof and /dev/null differ diff --git a/tests/proofs/webauthn.noir.vk b/tests/proofs/webauthn.noir.vk deleted file mode 100644 index de7fa05b2..000000000 Binary files a/tests/proofs/webauthn.noir.vk and /dev/null differ