qrllib 0.1.2

Rust QRL primitives, wallet helpers, and ML-DSA-87 implementation for native and wasm use
Documentation
use qrllib::{
    ADDRESS_SIZE, DESCRIPTOR_SIZE, Descriptor, ExtendedSeed, ML_DSA_87_CRYPTO_SEED_SIZE,
    ML_DSA_87_PUBLIC_KEY_SIZE, ML_DSA_87_SECRET_KEY_SIZE, ML_DSA_87_SIGNATURE_SIZE, MlDsa87,
    MlDsa87Wallet, QrllibError, SEED_SIZE, SPHINCS_PLUS_256S_PUBLIC_KEY_SIZE, Seed,
    SphincsPlus256s, SphincsPlus256sWallet, WalletType, bin_to_mnemonic,
    enable_experimental_sphincsplus_issuance_for_testing, extract_message, extract_signature,
    format_address, get_address, is_valid_address, mnemonic_to_bin, open,
    validate_mldsa_public_key, validate_mldsa_secret_key, verify_mldsa87_wallet_signature,
};

#[test]
fn descriptor_wallet_type_and_address_validation_paths_are_exercised() {
    let descriptor = Descriptor::mldsa87();
    assert_eq!(descriptor.type_code(), WalletType::MlDsa87.code());
    assert_eq!(descriptor.metadata(), [0, 0]);
    assert_eq!(descriptor.to_bytes().len(), DESCRIPTOR_SIZE);
    assert_eq!(descriptor.wallet_type().expect("wallet type"), WalletType::MlDsa87);

    let invalid_descriptor = Descriptor::new([9, 0, 0]);
    assert!(!invalid_descriptor.is_valid());
    assert!(invalid_descriptor.validate().is_err());
    assert!(Descriptor::from_bytes(&[1, 2]).is_err());

    for nonzero_metadata in [
        [WalletType::MlDsa87.code(), 0x01, 0x00],
        [WalletType::MlDsa87.code(), 0x00, 0x01],
        [WalletType::MlDsa87.code(), 0xff, 0xff],
        [WalletType::SphincsPlus256s.code(), 0x01, 0x00],
        [WalletType::SphincsPlus256s.code(), 0x00, 0x01],
        [WalletType::SphincsPlus256s.code(), 0xff, 0xff],
    ] {
        let descriptor = Descriptor::new(nonzero_metadata);
        assert!(
            !descriptor.is_valid(),
            "descriptor with non-zero metadata bytes must be rejected: {nonzero_metadata:?}"
        );
        assert!(descriptor.validate().is_err());
        assert!(
            ExtendedSeed::new(descriptor, &Seed::from_bytes(&[0_u8; SEED_SIZE]).unwrap()).is_err()
        );
        assert!(get_address(&[0_u8; ML_DSA_87_PUBLIC_KEY_SIZE], descriptor).is_err());
    }
    assert!(matches!(WalletType::try_from(9), Err(QrllibError::UnknownWalletType(9))));
    assert_eq!(WalletType::MlDsa87.expected_public_key_size(), ML_DSA_87_PUBLIC_KEY_SIZE);
    assert_eq!(WalletType::SphincsPlus256s.expected_public_key_size(), 64);
    assert_eq!(WalletType::MlDsa87.to_string(), "ML_DSA_87");
    assert_eq!(WalletType::SphincsPlus256s.to_string(), "SPHINCSPLUS_256S");

    let signer = MlDsa87::from_seed([7_u8; ML_DSA_87_CRYPTO_SEED_SIZE]);
    let address = get_address(&signer.public_key_bytes(), descriptor).expect("address");
    assert_eq!(address.len(), ADDRESS_SIZE);
    let address_string = format_address(&address);
    assert!(is_valid_address(&address_string));
    assert!(!is_valid_address("Qdeadbeef"));
    assert!(get_address(&[0_u8; 1], descriptor).is_err());
    assert!(get_address(&signer.public_key_bytes(), invalid_descriptor).is_err());
}

#[test]
fn seed_extended_seed_and_mnemonic_helpers_cover_round_trips() {
    let seed = Seed::generate().expect("random seed");
    let seed_hex = seed.to_hex_prefixed();
    let recovered_seed = Seed::from_hex(&seed_hex).expect("seed from hex");
    assert_eq!(seed, recovered_seed);
    assert_eq!(seed.sha256().len(), 32);
    assert_eq!(seed.shake256(96).len(), 96);
    assert!(Seed::from_bytes(&[0_u8; 1]).is_err());

    let descriptor = Descriptor::mldsa87();
    let extended_seed = ExtendedSeed::new(descriptor, &seed).expect("extended seed");
    let extended_seed_hex = extended_seed.to_hex_prefixed();
    let recovered_extended_seed =
        ExtendedSeed::from_hex(&extended_seed_hex).expect("extended seed from hex");
    assert_eq!(extended_seed, recovered_extended_seed);
    assert_eq!(recovered_extended_seed.descriptor(), descriptor);
    assert_eq!(recovered_extended_seed.seed(), seed);

    let mnemonic = bin_to_mnemonic(extended_seed.as_bytes()).expect("mnemonic");
    let mnemonic_bytes = mnemonic_to_bin(&mnemonic).expect("mnemonic to bin");
    assert_eq!(mnemonic_bytes, extended_seed.as_bytes());
    assert!(mnemonic_to_bin("aback invalid").is_err());
    assert!(bin_to_mnemonic(&[1_u8]).is_err());

    let mut zeroized_seed = seed.clone();
    zeroized_seed.zeroize();
    assert!(zeroized_seed.as_bytes().iter().all(|byte| *byte == 0));

    let mut zeroized_extended_seed = extended_seed.clone();
    zeroized_extended_seed.zeroize();
    assert!(zeroized_extended_seed.as_bytes().iter().all(|byte| *byte == 0));

    assert!(ExtendedSeed::new(Descriptor::new([9, 0, 0]), &seed).is_err());
    assert!(ExtendedSeed::from_bytes(&[0_u8; 4]).is_err());
}

#[test]
fn mldsa_public_api_covers_generation_import_export_and_zeroization() {
    let generated = MlDsa87::generate().expect("generated signer");
    let seed_hex = generated.hex_seed();
    let imported = MlDsa87::from_hex_seed(&seed_hex).expect("imported signer");
    assert_eq!(generated.public_key_bytes(), imported.public_key_bytes());

    let message = b"detached signatures in wasm";
    let signature = generated.sign(b"context", message).expect("signature");
    assert_eq!(signature.len(), ML_DSA_87_SIGNATURE_SIZE);
    assert_eq!(
        extract_message(&generated.sign_attached(b"context", message).expect("sealed"))
            .expect("message"),
        message
    );
    assert_eq!(extract_signature(&signature).expect("signature slice"), signature);
    assert!(extract_signature(&signature[..signature.len() - 1]).is_none());
    assert_eq!(extract_message(&signature).expect("empty message"), b"");

    let sealed = imported.sign_attached(b"context", message).expect("sealed message");
    let opened =
        open(b"context", &sealed, &imported.public_key_bytes()).expect("open").expect("opened");
    assert_eq!(opened, message);
    assert!(
        open(b"context", &[1_u8; 4], &imported.public_key_bytes()).expect("short open").is_none()
    );

    assert!(imported.verify(b"context", message, &signature).expect("verify"));
    assert!(MlDsa87::from_hex_seed("0x00").is_err());
    assert!(
        open(b"context", &sealed, &[0_u8; ML_DSA_87_PUBLIC_KEY_SIZE],)
            .expect("invalid verification")
            .is_none()
    );

    let mut zeroized = imported.clone();
    zeroized.zeroize();
    assert!(zeroized.seed().iter().all(|byte| *byte == 0));
    assert!(zeroized.secret_key_bytes().iter().all(|byte| *byte == 0));

    assert!(validate_mldsa_public_key(&imported.public_key_bytes()).is_ok());
    assert!(validate_mldsa_secret_key(imported.secret_key_bytes().as_slice()).is_ok());
    assert!(matches!(
        validate_mldsa_secret_key(&[0_u8; 1]),
        Err(QrllibError::InvalidMlDsaSecretKeySize(1, ML_DSA_87_SECRET_KEY_SIZE))
    ));
    assert!(matches!(
        validate_mldsa_public_key(&[0_u8; 1]),
        Err(QrllibError::InvalidPublicKeySize {
            wallet_type: WalletType::MlDsa87,
            actual: 1,
            expected: ML_DSA_87_PUBLIC_KEY_SIZE,
        })
    ));
}

#[test]
fn wallet_api_covers_seed_imports_generation_verification_and_zeroization() {
    let seed = Seed::from_bytes(&[11_u8; SEED_SIZE]).expect("seed");
    let wallet = MlDsa87Wallet::from_seed(seed.clone()).expect("wallet from seed");
    let wallet_from_raw_hex =
        MlDsa87Wallet::from_hex_seed(&seed.to_hex_prefixed()).expect("wallet from raw seed");
    assert_eq!(wallet.address(), wallet_from_raw_hex.address());

    let generated_wallet = MlDsa87Wallet::generate().expect("generated wallet");
    let signature = generated_wallet.sign(b"browser signing flow").expect("signature");
    assert!(verify_mldsa87_wallet_signature(
        b"browser signing flow",
        &signature,
        &generated_wallet.public_key(),
        generated_wallet.descriptor(),
    ));
    assert!(!verify_mldsa87_wallet_signature(
        b"browser signing flow",
        &signature,
        &generated_wallet.public_key(),
        Descriptor::new([0, 0, 0]),
    ));
    assert!(
        !verify_mldsa87_wallet_signature(
            b"browser signing flow",
            &signature,
            &generated_wallet.public_key(),
            Descriptor::new([WalletType::MlDsa87.code(), 0x01, 0x00]),
        ),
        "non-canonical ML-DSA-87 descriptor must not verify"
    );
    assert!(
        !verify_mldsa87_wallet_signature(
            b"browser signing flow",
            &signature,
            &generated_wallet.public_key(),
            Descriptor::new([WalletType::MlDsa87.code(), 0x00, 0xff]),
        ),
        "non-canonical ML-DSA-87 descriptor must not verify"
    );

    let wallet_hex = wallet.hex_seed().expect("wallet hex");
    let wallet_from_extended_hex =
        MlDsa87Wallet::from_hex_extended_seed(&wallet_hex).expect("wallet from extended hex");
    assert_eq!(wallet.address(), wallet_from_extended_hex.address());

    let sphincs_seed =
        ExtendedSeed::new(Descriptor::new([WalletType::SphincsPlus256s.code(), 0, 0]), &seed)
            .expect("sphincs extended seed");
    assert!(MlDsa87Wallet::from_extended_seed(sphincs_seed).is_err());

    let mut zeroized_wallet = wallet_from_extended_hex;
    zeroized_wallet.zeroize();
    assert!(zeroized_wallet.seed().as_bytes().iter().all(|byte| *byte == 0));
    assert!(zeroized_wallet.secret_key().iter().all(|byte| *byte == 0));
}

#[test]
fn sphincs_public_and_wallet_api_cover_generation_imports() {
    // Integration tests don't inherit qrllib's `cfg(test)` scope, so the
    // SPHINCS+ wallet issuance gate (TOB-QRLLIB-4) sees the test build
    // as a production build. Flip the runtime bypass before constructing
    // any `SphincsPlus256sWallet` from this test.
    enable_experimental_sphincsplus_issuance_for_testing();

    let generated = SphincsPlus256s::generate().expect("generated signer");
    assert_eq!(generated.public_key_bytes().len(), SPHINCS_PLUS_256S_PUBLIC_KEY_SIZE);

    let imported = SphincsPlus256s::from_hex_seed(&generated.hex_seed()).expect("imported signer");
    assert_eq!(generated.public_key_bytes(), imported.public_key_bytes());
    assert!(SphincsPlus256s::from_hex_seed("0x00").is_err());

    let seed = Seed::from_bytes(&[23_u8; SEED_SIZE]).expect("seed");
    let wallet = SphincsPlus256sWallet::from_hex_seed(&seed.to_hex_prefixed())
        .expect("wallet from raw seed");
    assert_eq!(wallet.seed(), seed);

    let generated_wallet = SphincsPlus256sWallet::generate().expect("generated wallet");
    assert!(is_valid_address(&generated_wallet.address_string()));
}

#[test]
fn error_messages_remain_human_readable() {
    let error = QrllibError::from(getrandom::Error::UNSUPPORTED);
    assert!(error.to_string().contains("getrandom"));
    assert_eq!(QrllibError::InvalidDescriptor.to_string(), "invalid descriptor");
    assert_eq!(
        QrllibError::InvalidMlDsaSeedSize(1, ML_DSA_87_CRYPTO_SEED_SIZE).to_string(),
        "invalid ML-DSA seed size 1, expected 32"
    );
}