Merge
Crypto WASM TESTS
This commit is contained in:
parent
2a00bb35e7
commit
687e6f9642
49 changed files with 6272 additions and 366 deletions
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
# The example workspace config points the type-map build script at this file.
|
||||||
|
MTP_TYPE_MAPS: example-type-maps.yaml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fmt:
|
||||||
|
name: rustfmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo fmt --all --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo test --workspace --exclude mtp-wasm --all-features
|
||||||
|
|
||||||
|
wasm:
|
||||||
|
name: wasm build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
# The WebTransport bindings in web-sys are still gated behind this cfg.
|
||||||
|
- run: cargo build -p mtp-wasm --target wasm32-unknown-unknown
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: --cfg=web_sys_unstable_apis
|
||||||
|
|
||||||
|
deny:
|
||||||
|
name: cargo-deny
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||||
|
with:
|
||||||
|
command: check
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -1,4 +1,18 @@
|
||||||
**target/
|
**target/
|
||||||
**Cargo.lock
|
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
example-usage/dev-cert/
|
||||||
|
|
||||||
|
# The root Cargo.lock and the example-usage workspace lockfile are committed:
|
||||||
|
# the workspace ships binaries (example-usage/*) and a committed lockfile keeps
|
||||||
|
# their builds reproducible. Workspace members do not own a lockfile (the root
|
||||||
|
# one governs them); ignore any stray per-member lockfiles so they are not
|
||||||
|
# committed by accident.
|
||||||
|
/client/Cargo.lock
|
||||||
|
/codec/Cargo.lock
|
||||||
|
/common/Cargo.lock
|
||||||
|
/crypto/Cargo.lock
|
||||||
|
/host/Cargo.lock
|
||||||
|
/transport/Cargo.lock
|
||||||
|
/type-map/Cargo.lock
|
||||||
|
/wasm/Cargo.lock
|
||||||
|
|
|
||||||
2576
Cargo.lock
generated
Normal file
2576
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
|
@ -9,6 +9,24 @@ members = [
|
||||||
"client",
|
"client",
|
||||||
"wasm",
|
"wasm",
|
||||||
]
|
]
|
||||||
|
# `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs
|
||||||
|
# (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the
|
||||||
|
# wasm32 target. Cargo only reads .cargo/config.toml from the invocation
|
||||||
|
# directory and its ancestors, so building it for the host target from the
|
||||||
|
# workspace root fails. Exclude it from the default set so a bare
|
||||||
|
# `cargo build`/`test`/`clippy` at the root matches CI, which always uses
|
||||||
|
# `--exclude mtp-wasm`. Build it explicitly with:
|
||||||
|
# cargo build -p mtp-wasm --target wasm32-unknown-unknown
|
||||||
|
default-members = [
|
||||||
|
".",
|
||||||
|
"common",
|
||||||
|
"crypto",
|
||||||
|
"type-map",
|
||||||
|
"codec",
|
||||||
|
"transport",
|
||||||
|
"host",
|
||||||
|
"client",
|
||||||
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -151,12 +151,12 @@ A `Keyring` bundles all secret and public key material for one identity:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct Keyring {
|
pub struct Keyring {
|
||||||
pub kem_secret_key: KemPrivateKey,
|
|
||||||
pub kem_public_key: KemPublicKey,
|
pub kem_public_key: KemPublicKey,
|
||||||
pub sig_cl_secret_key: SignaturePrivateKey, // Ed25519
|
pub kem_secret_key: KemPrivateKey,
|
||||||
pub sig_cl_public_key: SignaturePublicKey, // Ed25519
|
|
||||||
pub sig_pq_secret_key: SignaturePqPrivateKey, // ML-DSA-65
|
|
||||||
pub sig_pq_public_key: SignaturePqPublicKey, // ML-DSA-65
|
pub sig_pq_public_key: SignaturePqPublicKey, // ML-DSA-65
|
||||||
|
pub sig_pq_secret_key: SignaturePqPrivateKey,
|
||||||
|
pub sig_cl_public_key: SignaturePublicKey, // Ed25519
|
||||||
|
pub sig_cl_secret_key: SignaturePrivateKey,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -238,19 +238,24 @@ force-closes the QUIC connection if the peer has not already done so.
|
||||||
## Crypto Containers
|
## Crypto Containers
|
||||||
|
|
||||||
With the `crypto` feature, `DataValue` supports encrypted, signed, and
|
With the `crypto` feature, `DataValue` supports encrypted, signed, and
|
||||||
signed+encrypted containers:
|
signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a
|
||||||
|
recipient's KEM public key (from their `PublicKeyBundle`); only the holder of
|
||||||
|
the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::crypto::{ChaCha20Poly1305, Ed25519Signer, SigAlgorithm};
|
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
|
||||||
|
|
||||||
let cipher = ChaCha20Poly1305::new(derive_encryption_key(...));
|
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
||||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
||||||
|
|
||||||
|
// `recipient` is the PublicKeyBundle of whoever should be able to decrypt
|
||||||
|
// (e.g. the host's bundle, obtained out of band).
|
||||||
|
|
||||||
// Encrypted container
|
// Encrypted container
|
||||||
let mut enc = DataValue::Container(vec![
|
let mut enc = DataValue::Container(vec![
|
||||||
(DataTypeId(1), DataValue::Str("secret".into())),
|
(DataTypeId(1), DataValue::Str("secret".into())),
|
||||||
]);
|
]);
|
||||||
enc.encrypt_container(&cipher, b"aad");
|
enc.encrypt_container(enc_type, &recipient, b"aad");
|
||||||
|
|
||||||
// Signed container
|
// Signed container
|
||||||
let mut sig = DataValue::Container(vec![
|
let mut sig = DataValue::Container(vec![
|
||||||
|
|
@ -262,11 +267,18 @@ sig.sign_container(SigAlgorithm::ED25519, &signer);
|
||||||
let mut sec = DataValue::Container(vec![
|
let mut sec = DataValue::Container(vec![
|
||||||
(DataTypeId(1), DataValue::Str("both".into())),
|
(DataTypeId(1), DataValue::Str("both".into())),
|
||||||
]);
|
]);
|
||||||
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, &cipher, b"aad");
|
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
|
||||||
```
|
```
|
||||||
|
|
||||||
On the receiving side, use the corresponding `decrypt_into_container`,
|
On the receiving side, the recipient decrypts with its own `Keyring` (each blob
|
||||||
`verify_into_container`, or `decrypt_signed_encrypted_container` methods.
|
is self-describing — its leading byte selects the algorithm and the matching KEM
|
||||||
|
key from the keyring):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enc.decrypt_into_container(&keyring, b"aad"); // -> Container
|
||||||
|
sig.verify_into_container(&verifier); // verifier: impl SignatureScheme
|
||||||
|
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container
|
||||||
|
```
|
||||||
|
|
||||||
## Policy Configuration
|
## Policy Configuration
|
||||||
|
|
||||||
|
|
@ -321,5 +333,3 @@ version and expects the host to negotiate a compatible version.
|
||||||
| `AuthenticationFailed` | Nonce mismatch or invalid host signature |
|
| `AuthenticationFailed` | Nonce mismatch or invalid host signature |
|
||||||
| `ConnectionError` | QUIC connection failure |
|
| `ConnectionError` | QUIC connection failure |
|
||||||
| `UseAfterClosed` | Attempted send/receive after close |
|
| `UseAfterClosed` | Attempted send/receive after close |
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ if (!WasmClient.is_supported()) {
|
||||||
const config = new ConnectionConfig("https://host.example.com:4433");
|
const config = new ConnectionConfig("https://host.example.com:4433");
|
||||||
config.client_id = 12345n; // optional, for re-authentication
|
config.client_id = 12345n; // optional, for re-authentication
|
||||||
config.server_certificate_hashes = [ // optional, for certificate pinning
|
config.server_certificate_hashes = [ // optional, for certificate pinning
|
||||||
"sha256:abc123...",
|
"sha-256:abc123...",
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -55,11 +55,12 @@ providing its hash:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
config.server_certificate_hashes = [
|
config.server_certificate_hashes = [
|
||||||
"sha256:abcd1234...", // hex-encoded hash value
|
"sha-256:abcd1234...", // hex-encoded hash value
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
The hash format is `"<algorithm>:<hex-encoded-hash>"`. When hashes are
|
The hash format is `"<algorithm>:<hex-encoded-hash>"`, where the only algorithm
|
||||||
|
the browser's WebTransport API currently accepts is `sha-256`. When hashes are
|
||||||
provided, the browser **only** trusts certificates matching one of the given
|
provided, the browser **only** trusts certificates matching one of the given
|
||||||
hashes and ignores its root store for this connection.
|
hashes and ignores its root store for this connection.
|
||||||
|
|
||||||
|
|
@ -172,13 +173,16 @@ binary payload. Useful for health checks and simple messaging.
|
||||||
function build_demo_message(
|
function build_demo_message(
|
||||||
clientId: bigint,
|
clientId: bigint,
|
||||||
keyringBytes: Uint8Array,
|
keyringBytes: Uint8Array,
|
||||||
|
hostBundleBytes: Uint8Array,
|
||||||
): Uint8Array;
|
): Uint8Array;
|
||||||
```
|
```
|
||||||
|
|
||||||
Constructs a `Ping` frame that demonstrates encrypted, signed, and
|
Constructs a `Ping` frame that demonstrates encrypted, signed, and
|
||||||
signed+encrypted containers using a deterministic demo key. The paired host
|
signed+encrypted containers. The containers are ML-KEM-encrypted to the host's
|
||||||
handler can decrypt and verify these containers if it knows the same shared
|
`PublicKeyBundle` (`hostBundleBytes`, the same bytes passed to `auth_connect` /
|
||||||
secret.
|
`auth_register`), so the host decrypts them with its own keyring; signatures use
|
||||||
|
the client keyring's Ed25519 key. The client keyring only needs its Ed25519
|
||||||
|
signing key for this demo.
|
||||||
|
|
||||||
### `parse_auth_response`
|
### `parse_auth_response`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@ use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Versi
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
use mtp_transport::{Policy, Receiver, Sender};
|
use mtp_transport::{Policy, Receiver, Sender};
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
fn unexpected_response_type_error(
|
||||||
|
context: &str,
|
||||||
|
expected_type: mtp_codec::CommunicationTypeId,
|
||||||
|
response: &CommunicationValue,
|
||||||
|
) -> CommunicationError {
|
||||||
|
CommunicationError::AuthenticationFailed(format!(
|
||||||
|
"unexpected response type during {context}: expected {:?}, got {:?}; parsed {}",
|
||||||
|
expected_type,
|
||||||
|
response.get_type(),
|
||||||
|
response
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ClientConfig {
|
pub struct ClientConfig {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub server_cert: Option<Vec<u8>>,
|
pub server_cert: Option<Vec<u8>>,
|
||||||
|
|
@ -124,6 +138,15 @@ impl MTPClient {
|
||||||
|
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
|
|
||||||
|
let expected_type = mtp_codec::CommunicationType::IdentificationResponse.to_id(&tm);
|
||||||
|
if response.get_type() != expected_type {
|
||||||
|
return Err(unexpected_response_type_error(
|
||||||
|
"auth_connect",
|
||||||
|
expected_type,
|
||||||
|
&response,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let connected = response.get_data(DataType::Connected.to_id(&tm));
|
let connected = response.get_data(DataType::Connected.to_id(&tm));
|
||||||
match connected {
|
match connected {
|
||||||
DataValue::BoolTrue => {}
|
DataValue::BoolTrue => {}
|
||||||
|
|
@ -150,7 +173,7 @@ impl MTPClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_new_nonce = match response.get_data(DataType::Timestamp.to_id(&tm)) {
|
let host_new_nonce = match response.get_data(DataType::Timestamp.to_id(&tm)) {
|
||||||
DataValue::UnsignedNumber(n) => *n as u128,
|
DataValue::UnsignedNumber(n) => *n,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Missing new nonce".into(),
|
"Missing new nonce".into(),
|
||||||
|
|
@ -266,6 +289,15 @@ impl MTPClient {
|
||||||
|
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
|
|
||||||
|
let expected_type = mtp_codec::CommunicationType::RegisterResponse.to_id(&tm);
|
||||||
|
if response.get_type() != expected_type {
|
||||||
|
return Err(unexpected_response_type_error(
|
||||||
|
"auth_register",
|
||||||
|
expected_type,
|
||||||
|
&response,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let connected = response.get_data(DataType::Connected.to_id(&tm));
|
let connected = response.get_data(DataType::Connected.to_id(&tm));
|
||||||
match connected {
|
match connected {
|
||||||
DataValue::BoolTrue => {}
|
DataValue::BoolTrue => {}
|
||||||
|
|
@ -282,7 +314,7 @@ impl MTPClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let assigned_id = match response.get_data(DataType::Id.to_id(&tm)) {
|
let assigned_id = match response.get_data(DataType::Id.to_id(&tm)) {
|
||||||
DataValue::UnsignedNumber(n) => *n as u128,
|
DataValue::UnsignedNumber(n) => *n,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Missing assigned ID".into(),
|
"Missing assigned ID".into(),
|
||||||
|
|
@ -301,7 +333,7 @@ impl MTPClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_new_nonce = match response.get_data(DataType::Timestamp.to_id(&tm)) {
|
let host_new_nonce = match response.get_data(DataType::Timestamp.to_id(&tm)) {
|
||||||
DataValue::UnsignedNumber(n) => *n as u128,
|
DataValue::UnsignedNumber(n) => *n,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Missing new nonce".into(),
|
"Missing new nonce".into(),
|
||||||
|
|
|
||||||
|
|
@ -324,8 +324,7 @@ impl CommunicationValue {
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
let frame_signature = if is_signed {
|
let frame_signature = if is_signed {
|
||||||
let alg = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?;
|
let alg = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?;
|
||||||
let sig_len =
|
let sig_len = SigAlgorithm::length(alg).ok_or(CodecError::InvalidEncoding)?;
|
||||||
SigAlgorithm::length(alg).ok_or(CodecError::InvalidEncoding)?;
|
|
||||||
let mut sig = vec![0u8; sig_len];
|
let mut sig = vec![0u8; sig_len];
|
||||||
cursor
|
cursor
|
||||||
.read_exact(&mut sig)
|
.read_exact(&mut sig)
|
||||||
|
|
@ -389,11 +388,7 @@ impl CommunicationValue {
|
||||||
* signature before the data payload.
|
* signature before the data payload.
|
||||||
*/
|
*/
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub fn sign_frame(
|
pub fn sign_frame(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> {
|
||||||
&mut self,
|
|
||||||
algorithm: u8,
|
|
||||||
signer: &impl SignatureScheme,
|
|
||||||
) -> Option<()> {
|
|
||||||
let signed_payload = self.build_signed_payload().ok()?;
|
let signed_payload = self.build_signed_payload().ok()?;
|
||||||
let sig = signer.sign(&signed_payload).ok()?;
|
let sig = signer.sign(&signed_payload).ok()?;
|
||||||
self.frame_signature = Some((algorithm, sig));
|
self.frame_signature = Some((algorithm, sig));
|
||||||
|
|
|
||||||
|
|
@ -151,10 +151,17 @@ impl DataValue {
|
||||||
|
|
||||||
const KIND_NULL: u8 = 0xFF;
|
const KIND_NULL: u8 = 0xFF;
|
||||||
|
|
||||||
|
/// Smallest possible encoded entry, used to cap pre-reservation when
|
||||||
|
/// decoding containers/arrays so a small frame cannot force a huge
|
||||||
|
/// allocation from an attacker-controlled count. A bool/null entry in a
|
||||||
|
/// container is 3 bytes (1 kind + 2 key); a bare value in an array is 1
|
||||||
|
/// byte, so 1 is the safe lower bound shared by both.
|
||||||
|
const MIN_ENTRY_BYTES: usize = 1;
|
||||||
|
|
||||||
pub fn container_from_map(map: &BTreeMap<DataTypeId, DataValue>) -> DataValue {
|
pub fn container_from_map(map: &BTreeMap<DataTypeId, DataValue>) -> DataValue {
|
||||||
let mut container = Vec::new();
|
let mut container = Vec::new();
|
||||||
for (key, value) in map {
|
for (key, value) in map {
|
||||||
container.push((key.clone(), value.clone()));
|
container.push((*key, value.clone()));
|
||||||
}
|
}
|
||||||
DataValue::Container(container)
|
DataValue::Container(container)
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +414,7 @@ impl DataValue {
|
||||||
DataValue::Container(c) => {
|
DataValue::Container(c) => {
|
||||||
let mut out = BTreeMap::new();
|
let mut out = BTreeMap::new();
|
||||||
for (k, v) in c {
|
for (k, v) in c {
|
||||||
out.insert(k.clone(), v.clone());
|
out.insert(*k, v.clone());
|
||||||
}
|
}
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
@ -452,7 +459,7 @@ impl DataValue {
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?;
|
.map_err(|_| CodecError::InvalidEncoding)?;
|
||||||
|
|
||||||
for (key, value) in entries {
|
for (key, value) in entries {
|
||||||
Self::write_container_entry(&mut out, key.clone(), value)?;
|
Self::write_container_entry(&mut out, *key, value)?;
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
@ -520,14 +527,11 @@ impl DataValue {
|
||||||
match value {
|
match value {
|
||||||
DataValue::BoolTrue => Ok(()),
|
DataValue::BoolTrue => Ok(()),
|
||||||
DataValue::BoolFalse => Ok(()),
|
DataValue::BoolFalse => Ok(()),
|
||||||
|
#[allow(clippy::if_same_then_else)]
|
||||||
DataValue::Bool(v) => {
|
DataValue::Bool(v) => {
|
||||||
// Kept intentionally: the kind marker already encodes the boolean,
|
// Kept intentionally: the kind marker already encodes the boolean,
|
||||||
// so both arms carry no payload. Retained for clear compatibility.
|
// so both arms carry no payload. Retained for clear compatibility.
|
||||||
if *v {
|
if *v { Ok(()) } else { Ok(()) }
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DataValue::SignedNumber(n) => {
|
DataValue::SignedNumber(n) => {
|
||||||
buf.write_i128::<BigEndian>(*n)
|
buf.write_i128::<BigEndian>(*n)
|
||||||
|
|
@ -607,7 +611,11 @@ impl DataValue {
|
||||||
|
|
||||||
fn try_read_container(cursor: &mut Cursor<&[u8]>) -> Option<Self> {
|
fn try_read_container(cursor: &mut Cursor<&[u8]>) -> Option<Self> {
|
||||||
let count = cursor.read_u16::<BigEndian>().ok()? as usize;
|
let count = cursor.read_u16::<BigEndian>().ok()? as usize;
|
||||||
let mut entries = Vec::with_capacity(count);
|
let remaining = cursor
|
||||||
|
.get_ref()
|
||||||
|
.len()
|
||||||
|
.saturating_sub(cursor.position() as usize);
|
||||||
|
let mut entries = Vec::with_capacity(count.min(remaining / Self::MIN_ENTRY_BYTES));
|
||||||
|
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
let kind = cursor.read_u8().ok()?;
|
let kind = cursor.read_u8().ok()?;
|
||||||
|
|
@ -653,7 +661,11 @@ impl DataValue {
|
||||||
|
|
||||||
fn read_array(cursor: &mut Cursor<&[u8]>) -> Option<Self> {
|
fn read_array(cursor: &mut Cursor<&[u8]>) -> Option<Self> {
|
||||||
let count = cursor.read_u16::<BigEndian>().ok()? as usize;
|
let count = cursor.read_u16::<BigEndian>().ok()? as usize;
|
||||||
let mut out = Vec::with_capacity(count);
|
let remaining = cursor
|
||||||
|
.get_ref()
|
||||||
|
.len()
|
||||||
|
.saturating_sub(cursor.position() as usize);
|
||||||
|
let mut out = Vec::with_capacity(count.min(remaining / Self::MIN_ENTRY_BYTES));
|
||||||
|
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
let kind = cursor.read_u8().ok()?;
|
let kind = cursor.read_u8().ok()?;
|
||||||
|
|
@ -1185,6 +1197,22 @@ mod tests {
|
||||||
assert!(DataValue::from_bytes(&bytes[..0]).is_none());
|
assert!(DataValue::from_bytes(&bytes[..0]).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oversized_count_does_not_overallocate() {
|
||||||
|
// A frame declaring 65535 entries but carrying almost no payload must be
|
||||||
|
// rejected without pre-reserving a Vec for 65535 entries. The capacity is
|
||||||
|
// capped against remaining bytes, so these decode attempts allocate at
|
||||||
|
// most a handful of slots before failing.
|
||||||
|
// Container path: count = 0xFFFF, no entries follow.
|
||||||
|
assert!(DataValue::from_bytes(&[0xFF, 0xFF]).is_none());
|
||||||
|
// Container path with one stray byte after the count.
|
||||||
|
assert!(DataValue::from_bytes(&[0xFF, 0xFF, 0x01]).is_none());
|
||||||
|
// Array path: force the container parse to fail first, then the array
|
||||||
|
// parse also sees the oversized count. A leading kind byte that is not a
|
||||||
|
// valid container entry makes try_read_container bail to the array path.
|
||||||
|
assert!(DataValue::from_bytes(&[0xFF, 0xFF, 0x08, 0xFF, 0xFF]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_display_basic() {
|
fn test_display_basic() {
|
||||||
assert_eq!(format!("{}", DataValue::BoolTrue), "true");
|
assert_eq!(format!("{}", DataValue::BoolTrue), "true");
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ pub use data_value::{DataKind, DataValue};
|
||||||
pub use mtp_common::CodecError;
|
pub use mtp_common::CodecError;
|
||||||
|
|
||||||
pub use mtp_type_map::{
|
pub use mtp_type_map::{
|
||||||
communication_type_name, data_type_name, CommunicationType, CommunicationTypeId, DataType,
|
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
|
||||||
DataTypeId, TypeMap, Version, PROTOCOL_VERSION,
|
Version, communication_type_name, data_type_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn rand_u32() -> u32 {
|
pub(crate) fn rand_u32() -> u32 {
|
||||||
|
|
|
||||||
|
|
@ -251,9 +251,18 @@ mod communication_error_tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_communication_error_display() {
|
fn test_communication_error_display() {
|
||||||
assert_eq!(format!("{}", CommunicationError::UseAfterClosed), "Use after Closed");
|
assert_eq!(
|
||||||
assert_eq!(format!("{}", CommunicationError::StreamClosed), "Stream Closed");
|
format!("{}", CommunicationError::UseAfterClosed),
|
||||||
assert_eq!(format!("{}", CommunicationError::StreamError), "Stream Error");
|
"Use after Closed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", CommunicationError::StreamClosed),
|
||||||
|
"Stream Closed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", CommunicationError::StreamError),
|
||||||
|
"Stream Error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ impl ChaCha20Poly1305 {
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadEncrypt for ChaCha20Poly1305 {
|
impl AeadEncrypt for ChaCha20Poly1305 {
|
||||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
|
||||||
use chacha20poly1305::XChaCha20Poly1305;
|
use chacha20poly1305::XChaCha20Poly1305;
|
||||||
use chacha20poly1305::XNonce;
|
use chacha20poly1305::XNonce;
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
let key = chacha20poly1305::Key::from_slice(&self.key);
|
let key = chacha20poly1305::Key::from_slice(&self.key);
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
let cipher = XChaCha20Poly1305::new(key);
|
||||||
|
|
@ -63,9 +63,9 @@ impl AeadEncrypt for ChaCha20Poly1305 {
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadDecrypt for ChaCha20Poly1305 {
|
impl AeadDecrypt for ChaCha20Poly1305 {
|
||||||
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
|
||||||
use chacha20poly1305::XChaCha20Poly1305;
|
use chacha20poly1305::XChaCha20Poly1305;
|
||||||
use chacha20poly1305::XNonce;
|
use chacha20poly1305::XNonce;
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
if ciphertext.len() < 24 {
|
if ciphertext.len() < 24 {
|
||||||
return Err(CryptoError::InvalidNonceLength);
|
return Err(CryptoError::InvalidNonceLength);
|
||||||
|
|
@ -76,10 +76,7 @@ impl AeadDecrypt for ChaCha20Poly1305 {
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
let cipher = XChaCha20Poly1305::new(key);
|
||||||
let nonce_ref = XNonce::from_slice(nonce);
|
let nonce_ref = XNonce::from_slice(nonce);
|
||||||
|
|
||||||
let payload = Payload {
|
let payload = Payload { msg: ct, aad };
|
||||||
msg: ct,
|
|
||||||
aad,
|
|
||||||
};
|
|
||||||
|
|
||||||
cipher
|
cipher
|
||||||
.decrypt(nonce_ref, payload)
|
.decrypt(nonce_ref, payload)
|
||||||
|
|
@ -109,9 +106,9 @@ impl Aes256Gcm {
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
impl AeadEncrypt for Aes256Gcm {
|
impl AeadEncrypt for Aes256Gcm {
|
||||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
|
||||||
use aes_gcm::Aes256Gcm as AesGcmInner;
|
use aes_gcm::Aes256Gcm as AesGcmInner;
|
||||||
use aes_gcm::Nonce;
|
use aes_gcm::Nonce;
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key);
|
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key);
|
||||||
let cipher = AesGcmInner::new(key);
|
let cipher = AesGcmInner::new(key);
|
||||||
|
|
@ -139,9 +136,9 @@ impl AeadEncrypt for Aes256Gcm {
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
impl AeadDecrypt for Aes256Gcm {
|
impl AeadDecrypt for Aes256Gcm {
|
||||||
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
|
||||||
use aes_gcm::Aes256Gcm as AesGcmInner;
|
use aes_gcm::Aes256Gcm as AesGcmInner;
|
||||||
use aes_gcm::Nonce;
|
use aes_gcm::Nonce;
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
if ciphertext.len() < 12 {
|
if ciphertext.len() < 12 {
|
||||||
return Err(CryptoError::InvalidNonceLength);
|
return Err(CryptoError::InvalidNonceLength);
|
||||||
|
|
@ -152,10 +149,7 @@ impl AeadDecrypt for Aes256Gcm {
|
||||||
let cipher = AesGcmInner::new(key);
|
let cipher = AesGcmInner::new(key);
|
||||||
let nonce_ref = Nonce::from_slice(nonce);
|
let nonce_ref = Nonce::from_slice(nonce);
|
||||||
|
|
||||||
let payload = Payload {
|
let payload = Payload { msg: ct, aad };
|
||||||
msg: ct,
|
|
||||||
aad,
|
|
||||||
};
|
|
||||||
|
|
||||||
cipher
|
cipher
|
||||||
.decrypt(nonce_ref, payload)
|
.decrypt(nonce_ref, payload)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ pub fn sha256_double(data: &[u8]) -> [u8; 32] {
|
||||||
|
|
||||||
pub struct Sha256Hasher(sha2::Sha256);
|
pub struct Sha256Hasher(sha2::Sha256);
|
||||||
|
|
||||||
|
impl Default for Sha256Hasher {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Sha256Hasher {
|
impl Sha256Hasher {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(sha2::Sha256::new())
|
Self(sha2::Sha256::new())
|
||||||
|
|
|
||||||
|
|
@ -196,5 +196,3 @@ pub fn decrypt_multi(
|
||||||
}
|
}
|
||||||
Err(CryptoError::DecryptionFailed)
|
Err(CryptoError::DecryptionFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ pub struct HybridKem;
|
||||||
#[cfg(feature = "mlkem-tls")]
|
#[cfg(feature = "mlkem-tls")]
|
||||||
impl HybridKem {
|
impl HybridKem {
|
||||||
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
||||||
let (ek, dk) =
|
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
|
||||||
mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
|
|
||||||
(
|
(
|
||||||
KemPrivateKey::new(dk.as_bytes().to_vec()),
|
KemPrivateKey::new(dk.as_bytes().to_vec()),
|
||||||
KemPublicKey::new(ek.as_bytes().to_vec()),
|
KemPublicKey::new(ek.as_bytes().to_vec()),
|
||||||
|
|
@ -23,8 +22,7 @@ impl HybridKem {
|
||||||
pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> {
|
pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> {
|
||||||
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
|
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
|
||||||
.map_err(|_| CryptoError::KemEncapsulationFailed)?;
|
.map_err(|_| CryptoError::KemEncapsulationFailed)?;
|
||||||
let (ct, ss) =
|
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
|
||||||
mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
|
|
||||||
Ok(Encapsulated {
|
Ok(Encapsulated {
|
||||||
ciphertext: ct.as_bytes().to_vec(),
|
ciphertext: ct.as_bytes().to_vec(),
|
||||||
shared_secret: ss.as_bytes().to_vec(),
|
shared_secret: ss.as_bytes().to_vec(),
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,16 @@ pub use aead::ChaCha20Poly1305;
|
||||||
pub use aead::Aes256Gcm;
|
pub use aead::Aes256Gcm;
|
||||||
|
|
||||||
#[cfg(feature = "ed25519-dalek")]
|
#[cfg(feature = "ed25519-dalek")]
|
||||||
pub use sign::{verify_ed25519, Ed25519Signer, SignatureScheme};
|
pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519};
|
||||||
|
|
||||||
#[cfg(feature = "ml-dsa")]
|
#[cfg(feature = "ml-dsa")]
|
||||||
pub use sign::{verify_ml_dsa, MlDsaSigner};
|
pub use sign::{MlDsaSigner, verify_ml_dsa};
|
||||||
|
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub use sign::{sign_dual, DualSignature};
|
pub use sign::{DualSignature, sign_dual};
|
||||||
|
|
||||||
#[cfg(feature = "sha2")]
|
#[cfg(feature = "sha2")]
|
||||||
pub use hash::{sha256, sha256_double, Sha256Hasher};
|
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
||||||
|
|
||||||
#[cfg(feature = "hkdf")]
|
#[cfg(feature = "hkdf")]
|
||||||
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
||||||
|
|
@ -59,7 +59,7 @@ pub use enc::EncryptionType;
|
||||||
pub use enc::{decrypt_with, encrypt_for};
|
pub use enc::{decrypt_with, encrypt_for};
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||||
pub use helper::{decrypt_multi, encrypt_multi, MultiEncryptedMessage, RecipientEntry};
|
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
|
||||||
|
|
||||||
/* ================================ TESTS ================================ */
|
/* ================================ TESTS ================================ */
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -148,12 +148,7 @@ mod tests {
|
||||||
let (ed_signer, _, _) = Ed25519Signer::generate();
|
let (ed_signer, _, _) = Ed25519Signer::generate();
|
||||||
let (ml_signer, _, _) = MlDsaSigner::generate();
|
let (ml_signer, _, _) = MlDsaSigner::generate();
|
||||||
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap();
|
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap();
|
||||||
dual
|
dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg")
|
||||||
.verify(
|
|
||||||
ed_signer.verifying_key(),
|
|
||||||
ml_signer.verifying_key(),
|
|
||||||
b"msg",
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,9 +160,14 @@ mod tests {
|
||||||
let (ed_signer, _, _) = Ed25519Signer::generate();
|
let (ed_signer, _, _) = Ed25519Signer::generate();
|
||||||
let (ml_signer, _, _) = MlDsaSigner::generate();
|
let (ml_signer, _, _) = MlDsaSigner::generate();
|
||||||
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap();
|
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap();
|
||||||
assert!(dual
|
assert!(
|
||||||
.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"wrong")
|
dual.verify(
|
||||||
.is_err());
|
ed_signer.verifying_key(),
|
||||||
|
ml_signer.verifying_key(),
|
||||||
|
b"wrong"
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "hkdf")]
|
#[cfg(feature = "hkdf")]
|
||||||
|
|
@ -255,9 +255,18 @@ mod tests {
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let bytes = kr.to_bytes();
|
let bytes = kr.to_bytes();
|
||||||
let loaded = Keyring::from_bytes(&bytes).unwrap();
|
let loaded = Keyring::from_bytes(&bytes).unwrap();
|
||||||
assert_eq!(kr.kem_public_key.as_bytes(), loaded.kem_public_key.as_bytes());
|
assert_eq!(
|
||||||
assert_eq!(kr.sig_pq_public_key.as_bytes(), loaded.sig_pq_public_key.as_bytes());
|
kr.kem_public_key.as_bytes(),
|
||||||
assert_eq!(kr.sig_cl_public_key.as_bytes(), loaded.sig_cl_public_key.as_bytes());
|
loaded.kem_public_key.as_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kr.sig_pq_public_key.as_bytes(),
|
||||||
|
loaded.sig_pq_public_key.as_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kr.sig_cl_public_key.as_bytes(),
|
||||||
|
loaded.sig_cl_public_key.as_bytes()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
|
||||||
|
|
@ -267,7 +276,10 @@ mod tests {
|
||||||
let bundle = kr.public_key_bundle();
|
let bundle = kr.public_key_bundle();
|
||||||
let bytes = bundle.as_bytes();
|
let bytes = bundle.as_bytes();
|
||||||
let loaded = PublicKeyBundle::from_bytes(&bytes).unwrap();
|
let loaded = PublicKeyBundle::from_bytes(&bytes).unwrap();
|
||||||
assert_eq!(bundle.kem_public_key.as_bytes(), loaded.kem_public_key.as_bytes());
|
assert_eq!(
|
||||||
|
bundle.kem_public_key.as_bytes(),
|
||||||
|
loaded.kem_public_key.as_bytes()
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bundle.sig_pq_public_key.as_bytes(),
|
bundle.sig_pq_public_key.as_bytes(),
|
||||||
loaded.sig_pq_public_key.as_bytes()
|
loaded.sig_pq_public_key.as_bytes()
|
||||||
|
|
@ -290,8 +302,8 @@ mod tests {
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn encrypt_multi_roundtrip() {
|
fn encrypt_multi_roundtrip() {
|
||||||
|
use crate::helper::{decrypt_multi, encrypt_multi};
|
||||||
use crate::keypair::Keyring;
|
use crate::keypair::Keyring;
|
||||||
use crate::helper::{encrypt_multi, decrypt_multi};
|
|
||||||
|
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let entities = vec![kr.public_key_bundle()];
|
let entities = vec![kr.public_key_bundle()];
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,9 @@ impl MlDsaSigner {
|
||||||
impl SignatureScheme for MlDsaSigner {
|
impl SignatureScheme for MlDsaSigner {
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use ml_dsa::Signer;
|
use ml_dsa::Signer;
|
||||||
let signature = self.secret.try_sign(msg)
|
let signature = self
|
||||||
|
.secret
|
||||||
|
.try_sign(msg)
|
||||||
.map_err(|_| CryptoError::SigningFailed)?;
|
.map_err(|_| CryptoError::SigningFailed)?;
|
||||||
Ok(signature.encode().to_vec())
|
Ok(signature.encode().to_vec())
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +183,8 @@ impl SignatureScheme for MlDsaSigner {
|
||||||
use ml_dsa::Verifier;
|
use ml_dsa::Verifier;
|
||||||
let sig = ml_dsa::Signature::<ml_dsa::MlDsa65>::try_from(signature)
|
let sig = ml_dsa::Signature::<ml_dsa::MlDsa65>::try_from(signature)
|
||||||
.map_err(|_| CryptoError::InvalidSignature)?;
|
.map_err(|_| CryptoError::InvalidSignature)?;
|
||||||
self.public.verify(msg, &sig)
|
self.public
|
||||||
|
.verify(msg, &sig)
|
||||||
.map_err(|_| CryptoError::VerificationFailed)
|
.map_err(|_| CryptoError::VerificationFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
deny.toml
Normal file
32
deny.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# cargo-deny configuration. See https://embarkstudios.github.io/cargo-deny/
|
||||||
|
# Run locally with: cargo deny check
|
||||||
|
|
||||||
|
[advisories]
|
||||||
|
# Fail on any security advisory affecting the dependency tree.
|
||||||
|
yanked = "deny"
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
# Flag multiple versions of the same crate so duplicate trees are visible.
|
||||||
|
multiple-versions = "warn"
|
||||||
|
wildcards = "deny"
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
# Allowlist of licenses acceptable for this project's dependencies.
|
||||||
|
allow = [
|
||||||
|
"MIT",
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Zlib",
|
||||||
|
"MPL-2.0",
|
||||||
|
]
|
||||||
|
confidence-threshold = 0.8
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
4
example-usage/.gitignore
vendored
4
example-usage/.gitignore
vendored
|
|
@ -3,5 +3,9 @@ host_keys.json
|
||||||
host_sig_pk.bin
|
host_sig_pk.bin
|
||||||
host_sig_pq_pk.bin
|
host_sig_pq_pk.bin
|
||||||
host_enc_kem_pk.bin
|
host_enc_kem_pk.bin
|
||||||
|
host_public_key_bundle.hex
|
||||||
clients.json
|
clients.json
|
||||||
web-client/node_modules
|
web-client/node_modules
|
||||||
|
dev-cert/
|
||||||
|
web-client/public/host_public_key_bundle.hex
|
||||||
|
web-client/public/mtp_dev_cert_hash.txt
|
||||||
|
|
|
||||||
2361
example-usage/Cargo.lock
generated
Normal file
2361
example-usage/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,13 +2,29 @@ mod auth;
|
||||||
mod messages;
|
mod messages;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use mtp::client::ClientConfig;
|
use mtp::client::ClientConfig;
|
||||||
use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey};
|
use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey};
|
||||||
|
|
||||||
|
fn dev_cert_path() -> String {
|
||||||
|
std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
||||||
|
if Path::new("example-usage/dev-cert/cert.pem").exists() {
|
||||||
|
"example-usage/dev-cert/cert.pem".to_string()
|
||||||
|
} else {
|
||||||
|
"dev-cert/cert.pem".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cert_pem = fs::read("server.pem").expect("Missing server.pem: run server first");
|
let cert_path = dev_cert_path();
|
||||||
|
let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}"
|
||||||
|
)
|
||||||
|
});
|
||||||
let host_public_key = PublicKeyBundle::new(
|
let host_public_key = PublicKeyBundle::new(
|
||||||
KemPublicKey::new(
|
KemPublicKey::new(
|
||||||
fs::read("host_enc_kem_pk.bin")
|
fs::read("host_enc_kem_pk.bin")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ pub fn build_demo_message(
|
||||||
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
|
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
|
||||||
|
|
||||||
let inner_sec = DataValue::Container(vec![
|
let inner_sec = DataValue::Container(vec![
|
||||||
(DataTypeId(1), DataValue::Str("signed+encrypted payload".into())),
|
(
|
||||||
|
DataTypeId(1),
|
||||||
|
DataValue::Str("signed+encrypted payload".into()),
|
||||||
|
),
|
||||||
(DataTypeId(2), DataValue::UnsignedNumber(7)),
|
(DataTypeId(2), DataValue::UnsignedNumber(7)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sec = inner_sec;
|
let mut dv_sec = inner_sec;
|
||||||
|
|
@ -38,13 +41,22 @@ pub fn build_demo_message(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
CommunicationValue::new(CommunicationType::Ping)
|
let msg = CommunicationValue::new(CommunicationType::Ping)
|
||||||
.add_typed_default(DataType::Description, DataValue::Str("MTP Data Type Demo".into()))
|
.add_typed_default(
|
||||||
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(timestamp as u128))
|
DataType::Description,
|
||||||
|
DataValue::Str("MTP Data Type Demo".into()),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::Timestamp,
|
||||||
|
DataValue::UnsignedNumber(timestamp as u128),
|
||||||
|
)
|
||||||
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
|
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
|
||||||
.add_typed_default(DataType::Flags, DataValue::BoolTrue)
|
.add_typed_default(DataType::Flags, DataValue::BoolTrue)
|
||||||
.add_typed_default(DataType::Value, DataValue::Float(2, 12345))
|
.add_typed_default(DataType::Value, DataValue::Float(2, 12345))
|
||||||
.add_typed_default(DataType::BinaryData, DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]))
|
.add_typed_default(
|
||||||
|
DataType::BinaryData,
|
||||||
|
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]),
|
||||||
|
)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::Items,
|
DataType::Items,
|
||||||
DataValue::Array(vec![
|
DataValue::Array(vec![
|
||||||
|
|
@ -56,7 +68,8 @@ pub fn build_demo_message(
|
||||||
.add_typed_default(DataType::EncryptedPayload, dv_enc)
|
.add_typed_default(DataType::EncryptedPayload, dv_enc)
|
||||||
.add_typed_default(DataType::SignedPayload, dv_sig)
|
.add_typed_default(DataType::SignedPayload, dv_sig)
|
||||||
.add_typed_default(DataType::SecurePayload, dv_sec)
|
.add_typed_default(DataType::SecurePayload, dv_sec)
|
||||||
.with_sender(client_id)
|
.with_sender(client_id);
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_and_receive(
|
pub async fn send_and_receive(
|
||||||
|
|
@ -69,7 +82,9 @@ pub async fn send_and_receive(
|
||||||
conn.sender.send(&msg).await?;
|
conn.sender.send(&msg).await?;
|
||||||
|
|
||||||
match conn.receiver.receive().await {
|
match conn.receiver.receive().await {
|
||||||
Ok(resp) => println!("Received: {resp}"),
|
Ok(resp) => {
|
||||||
|
println!("Received: {resp}");
|
||||||
|
}
|
||||||
Err(e) => eprintln!("Receive error: {e}"),
|
Err(e) => eprintln!("Receive error: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
4
example-usage/host_keys.json.stale-no-kem
Normal file
4
example-usage/host_keys.json.stale-no-kem
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"host_id": 1,
|
||||||
|
"keyring": "0000000007a0cc83597da99032dfe763d1e27eb368420b023be3518323638a09c10446e5a1e182beca1c0e8dda17a7b4b69cc0bbf1ec45f0b03c6b3c1052a540f69e4140f7d1d11039398f77a45630b7fe3254b001ce08d5dd92c6cba881ed20f4eefa6153608d7fb766bdb038939348ba091806c8bc5608650ae8c187be3bf90384b3cd227041462ba409d521c3970c7cc3fce096058f2648c6e2e242273e0af2c47fbf4d899be75b910e7d357618248d51c6e608e9c7a247e88f5961cad53bc163cea869a54c4abbc3d6ebbffa918296f4a21694f83941335ac061068ae67edce3783b8a6afb2676171906ba862a97df2b11dc9911d7e654718d2ecfb9e4b69217f50065981f15ce1d7b0b6d249f68a357c9c56b6c3edfbcf876713e7f6376baa181a4535d0ab5811c2b7e7ffef23cfb6a4af0ea4e6e2f55685d594e97a20c0fac2246618120117879f521f7bb2a26a4f697e9451e3f3653695235dac13feeb0cb8999ff41fad6bf55426877bbae8fd1cd8622c2cf90a57701a7136f01a3d03202b6b1488f70c5840ced7509425b1c3ca019bd55060509a8f2bb42977c78187d7d2801309af9ae2f609cb918c1956b0f282fa4eb591b530c7f5f83c1d54bacabe4abe34780e6c6802509082ad38ec1bcc6105eae77e67e3d414c7eb248d3f5582681e9fbbd5183fa978604f094b9013804055745deba587086f2eeb95a911d1cb4d0dd960c64ecfaad2ae0981e44d38c2441b5df03c9ea23338be78dcd0d4e8712ff6e9ab5d751870e7243c06835e2e7a14b7d3005251ab66fbff4820c0e03317035ead18e213846af6325d3e871524708eaa3093f883e223281a8f1be55cd3dc084dbdd461566c35f6a0d418dcc66ab1697242bf1016283f4e50e76e8f42feae937d4b908d6132c7d80755a58120d75489931f58de641f2f8676fca6b60da18e977b38108d8ffe98f96165222c07520c06974b39aa8b5139458ea101727f95818e69a6afcf0fa028224e8c0af5cd4f32db0a0583eb42a11fe7fa08c424563dde4e2977c898f2f68b2f183016c44dba013851d909844981d0d2635e7fe4af8f80ca3acbea30d11a2b92cf4c4866765493217fd078aa8903ea50aeace282d90a156cc37714f95d109c5421f947209656305beffa891717c7bc8802bb3eab87fc753b0450155e5f0b0c7f14bd66db4e69d330b64e8fc6cbc946e515536b06f65966a982435af15c4b612d25434ef3bc98c33c47abc3b98159733c5f03f541342e6d5e1c0ca6caa00e5616cee151771dbf3a73d571ee0b37ae54d4672e411feb1364f5b820dbc0912e8c43a8213d41576d8b207ab8564537a23aa99c08f6079b83a63c376d10b919023cb57363e7562527e634cc45f5131ae3204615ac078bb216677e65cafb2a95246635f76e1a1a7659d8be263ef403395e16ca388ec1b58b51376b221ab64309bb493687e6cfa7587b603a53e00b1eb4d413f299d7741e1de70df3dc6d3190a8c84fffb7a7901d524f4b5d9a7229c5c99f61faf3d5295760f35d3d54bbea944f044c35a2202e6d2865369809d0df2f64151fb83b3a74d8ad1e7b6cc6d0088a535f7ca6dc384bd93cef50132a0d882caac96acecd15d6eeb600ca6c25f7ff19795cf8595d62bdf5022eadc99a471a36a2b46cd1c4f4d999560eadf4994f77c50083eaad96f574100b594a5272b661c80ffed36a364e7aa69e5a0c69f1af9ba44eeb50e892309ca442ec09f0c716d1483f4d74efbf12ff845f5252e83e64d28a83541b5fbef87296e8a19c84c7c6fa46f61299f7375d74c8441e432613a5f721548a010406ab56f18c997a48d74970f6e651f5d1223e233b5b2ff952e3824b72f92755f491110517d71878e7469beaab6c0835c6467598f7969f1b85157dabebb1003b1f9081bab2aa2296a269a35c34f494e706291db5923690c19e4eaafa5bc3e838e3c6434d0ab91c0b95ce4221ed9cfbe011fa24168d821bed5af6f2fbcb75db1e1ea2a71e5a5f38ad4fab3ebf61983745f4464a0fcf5917415b01846d037fdb628637241070287cbcdcdf638353f01c03030bb36df7f48b5b1eba6152d4f569d034b5f2c9b61b1cf0402df910947d100f54bd0b0261445e60c0f4b1e1df292dbf26bb85ab327a19a33bca531d497b4e7181ba54881cfa1b43d35da64436850d298077877bd41cfd172d91413fee1888ad603a2368fedd611690878ca1f789a6fd21b6932b868bb3084e689c8995113478bbf6ba8ad04e937be34dce58b1117ebbd2eddde489fb34028e117b59fac89bf9a6b5970a989840447d3649f6e955fd0561a56d4038fac4fa4f67515400ecbf256a9339a6b61a62ffc101fc7241422e991b54215f2f6ae4d719d99e660cc6800bf33ce2a00a2b8d101908eb32a31ecb311f8d2da5509a4f0800515193d336a716a52868813a8ea5b878aacd5dca3753c66ea38c3f1f48529746581cbc375cdef323d885ee119f4b1397f5828dec90923caae9845c1f189c482d68342a70d979b95943287127996ce137cfdc2ce3f6ecbfe5f5f5b90b8260fc662c897d4cc1414ad77314f4c15b84308f5836d33d8183388dbbc0d15aa2a9c4c14f447450861e373544c25a61b874ba6716af6bce7bec2a42d6408e73fbf0a5b27fbd9aa3055bfd7b0a79cac66c6f51f00ab90bcb0dc28254a8a4fdbfb7c5bc63a6aa7227d0e335eb0eb01124582db8c7b92f8e84515acddc7bb24e2b3059329228c197e3432bead261b2dbf1798491ebb11658700201d4224bc1f165129ac6fac5909a664ef107f70035019ff7051bba3dc980ac3da0020faa51b18eb51dd316605d3842f71c23a1a15ec71c1cc5d650dabe60da9dc7c5600209300a95b9dcf41d3ee469fc9937737d572915f10e5ea2da286e13bf2dbb54ef6"
|
||||||
|
}
|
||||||
|
|
@ -14,3 +14,4 @@ tokio = { version = "1", features = ["full"] }
|
||||||
serde_json = { version = "1" }
|
serde_json = { version = "1" }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
serde_core = "1.0.228"
|
serde_core = "1.0.228"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,18 @@ pub fn load_client_db(
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>>
|
) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>>
|
||||||
{
|
{
|
||||||
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> =
|
let clients_map = match fs::read_to_string(path) {
|
||||||
Arc::new(Mutex::new(if let Ok(data) = fs::read_to_string(path) {
|
Ok(data) => match serde_json::from_str(&data) {
|
||||||
serde_json::from_str(&data).unwrap_or_default()
|
Ok(clients) => clients,
|
||||||
} else {
|
Err(e) => {
|
||||||
|
eprintln!("Failed to parse {path}; starting with empty client database: {e}");
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
}));
|
}
|
||||||
let next_id = Arc::new(Mutex::new(
|
},
|
||||||
clients.lock().unwrap().keys().max().unwrap_or(&999) + 1,
|
Err(_) => HashMap::new(),
|
||||||
));
|
};
|
||||||
|
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = Arc::new(Mutex::new(clients_map));
|
||||||
|
let next_value = clients.lock().unwrap().keys().max().unwrap_or(&999) + 1;
|
||||||
|
let next_id = Arc::new(Mutex::new(next_value));
|
||||||
Ok((clients, next_id))
|
Ok((clients, next_id))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ pub fn process_and_respond(
|
||||||
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!(" EncryptedPayload decryption failed");
|
||||||
enc_status = String::from("EncryptedPayload: decryption FAILED");
|
enc_status = String::from("EncryptedPayload: decryption FAILED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,13 +76,14 @@ pub fn process_and_respond(
|
||||||
if dv.verify_into_container(&verifier).is_some() {
|
if dv.verify_into_container(&verifier).is_some() {
|
||||||
if let Some(entries) = dv.as_container() {
|
if let Some(entries) = dv.as_container() {
|
||||||
println!(" Verified SignedPayload: {:?}", entries);
|
println!(" Verified SignedPayload: {:?}", entries);
|
||||||
sig_status =
|
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
|
||||||
format!("SignedPayload verified OK ({} entries)", entries.len());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!(" SignedPayload verification failed");
|
||||||
sig_status = String::from("SignedPayload: verification FAILED");
|
sig_status = String::from("SignedPayload: verification FAILED");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!(" SignedPayload cannot be verified; no client public key available");
|
||||||
sig_status = String::from("SignedPayload: no client public key available");
|
sig_status = String::from("SignedPayload: no client public key available");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,9 +104,11 @@ pub fn process_and_respond(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!(" SecurePayload decryption/verification failed");
|
||||||
secure_status = String::from("SecurePayload: decryption/verification FAILED");
|
secure_status = String::from("SecurePayload: decryption/verification FAILED");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!(" SecurePayload cannot be verified; no client public key available");
|
||||||
secure_status = String::from("SecurePayload: no client public key available");
|
secure_status = String::from("SecurePayload: no client public key available");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner};
|
|
||||||
use mtp::crypto::kem::HybridKem;
|
use mtp::crypto::kem::HybridKem;
|
||||||
|
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner};
|
||||||
|
|
||||||
pub fn load_or_generate_host_keys(path: &str) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
|
pub fn load_or_generate_host_keys(
|
||||||
|
path: &str,
|
||||||
|
) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
|
||||||
if let Ok(data) = fs::read_to_string(path) {
|
if let Ok(data) = fs::read_to_string(path) {
|
||||||
let json: serde_json::Value = serde_json::from_str(&data)?;
|
let json: serde_json::Value = serde_json::from_str(&data)?;
|
||||||
let hid = json["host_id"].as_u64().unwrap_or(1);
|
let hid = json["host_id"].as_u64().unwrap_or(1);
|
||||||
|
|
@ -27,6 +29,14 @@ pub fn load_or_generate_host_keys(path: &str) -> Result<(u64, Keyring), Box<dyn
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes());
|
||||||
|
|
||||||
|
fs::write("host_public_key_bundle.hex", &public_key_bundle_hex)?;
|
||||||
|
fs::create_dir_all("web-client/public")?;
|
||||||
|
fs::write(
|
||||||
|
"web-client/public/host_public_key_bundle.hex",
|
||||||
|
&public_key_bundle_hex,
|
||||||
|
)?;
|
||||||
fs::write(
|
fs::write(
|
||||||
"host_enc_kem_pk.bin",
|
"host_enc_kem_pk.bin",
|
||||||
host_keyring.kem_public_key.as_bytes(),
|
host_keyring.kem_public_key.as_bytes(),
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,34 @@ mod tls;
|
||||||
|
|
||||||
use mtp::host::{HostConfig, MTPHost};
|
use mtp::host::{HostConfig, MTPHost};
|
||||||
use mtp::type_map::TypeMap;
|
use mtp::type_map::TypeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn dev_cert_paths() -> (String, String) {
|
||||||
|
let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
||||||
|
if Path::new("example-usage/dev-cert/cert.pem").exists() {
|
||||||
|
"example-usage/dev-cert/cert.pem".to_string()
|
||||||
|
} else {
|
||||||
|
"dev-cert/cert.pem".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let key = std::env::var("MTP_DEV_KEY").unwrap_or_else(|_| {
|
||||||
|
if Path::new("example-usage/dev-cert/key.pem").exists() {
|
||||||
|
"example-usage/dev-cert/key.pem".to_string()
|
||||||
|
} else {
|
||||||
|
"dev-cert/key.pem".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(cert, key)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (cert_pem, key_pem) = tls::load_or_generate_tls("server.pem", "server.key")?;
|
let (cert_path, key_path) = dev_cert_paths();
|
||||||
|
let (cert_pem, key_pem) = tls::load_or_generate_tls(&cert_path, &key_path)?;
|
||||||
|
let cert_hash = tls::certificate_sha256_hex(&cert_pem)?;
|
||||||
|
tls::export_webtransport_cert_hash(&cert_hash)?;
|
||||||
|
println!("WebTransport certificate sha256: {cert_hash}");
|
||||||
|
|
||||||
let (host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?;
|
let (host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?;
|
||||||
keys::export_host_public_keys(&host_keyring)?;
|
keys::export_host_public_keys(&host_keyring)?;
|
||||||
|
|
||||||
|
|
@ -21,7 +45,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
let clients_for_get = clients.clone();
|
let clients_for_get = clients.clone();
|
||||||
let get_existing_user = Box::new(move |id: u64| -> Option<mtp::crypto::PublicKeyBundle> {
|
let get_existing_user = Box::new(move |id: u64| -> Option<mtp::crypto::PublicKeyBundle> {
|
||||||
clients_for_get.lock().unwrap().get(&id).cloned()
|
let result = clients_for_get.lock().unwrap().get(&id).cloned();
|
||||||
|
if result.is_some() {
|
||||||
|
println!("Auth lookup: client ID {id} found");
|
||||||
|
} else {
|
||||||
|
eprintln!("Auth lookup: unknown client ID {id}");
|
||||||
|
}
|
||||||
|
result
|
||||||
});
|
});
|
||||||
|
|
||||||
let clients_for_register = clients.clone();
|
let clients_for_register = clients.clone();
|
||||||
|
|
@ -33,7 +63,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let id = *nid;
|
let id = *nid;
|
||||||
*nid += 1;
|
*nid += 1;
|
||||||
db.insert(id, bundle);
|
db.insert(id, bundle);
|
||||||
std::fs::write(&clients_path, serde_json::to_string_pretty(&*db).unwrap()).ok();
|
match serde_json::to_string_pretty(&*db) {
|
||||||
|
Ok(json) => match std::fs::write(&clients_path, json) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => eprintln!("Failed to persist client database to {clients_path}: {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => eprintln!("Failed to serialize client database after registering {id}: {e}"),
|
||||||
|
}
|
||||||
println!("Registered new client with ID: {}", id);
|
println!("Registered new client with ID: {}", id);
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
pub fn load_or_generate_tls(
|
pub fn load_or_generate_tls(
|
||||||
cert_path: &str,
|
cert_path: &str,
|
||||||
|
|
@ -10,6 +13,12 @@ pub fn load_or_generate_tls(
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Generating self-signed TLS certificate ...");
|
println!("Generating self-signed TLS certificate ...");
|
||||||
|
if let Some(parent) = Path::new(cert_path).parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
if let Some(parent) = Path::new(key_path).parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
let key_pair = rcgen::KeyPair::generate()?;
|
let key_pair = rcgen::KeyPair::generate()?;
|
||||||
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
|
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
|
||||||
let cert = params.self_signed(&key_pair)?;
|
let cert = params.self_signed(&key_pair)?;
|
||||||
|
|
@ -23,3 +32,39 @@ pub fn load_or_generate_tls(
|
||||||
|
|
||||||
Ok((cert_str.into_bytes(), key_str.into_bytes()))
|
Ok((cert_str.into_bytes(), key_str.into_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let der = if cert.starts_with(b"-----BEGIN CERTIFICATE-----") {
|
||||||
|
let pem = std::str::from_utf8(cert)?;
|
||||||
|
let base64 = pem
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.starts_with("-----"))
|
||||||
|
.collect::<String>();
|
||||||
|
base64::engine::general_purpose::STANDARD.decode(base64)?
|
||||||
|
} else {
|
||||||
|
cert.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(hex::encode(mtp::crypto::sha256(&der)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let public_dir = if Path::new("web-client").exists() {
|
||||||
|
Path::new("web-client/public")
|
||||||
|
} else {
|
||||||
|
Path::new("example-usage/web-client/public")
|
||||||
|
};
|
||||||
|
fs::create_dir_all(public_dir)?;
|
||||||
|
fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash)?;
|
||||||
|
|
||||||
|
let dev_cert_dir = if Path::new("dev-cert").exists() {
|
||||||
|
Path::new("dev-cert")
|
||||||
|
} else {
|
||||||
|
Path::new("example-usage/dev-cert")
|
||||||
|
};
|
||||||
|
if dev_cert_dir.exists() {
|
||||||
|
fs::write(dev_cert_dir.join("sha256.txt"), hash)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MTP Web Client</title>
|
<title>MTP Web Client</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: monospace; background: #111; color: #0f0; padding: 2rem; }
|
body { background: #111; color: #eee; font-family: "Public Sans", sans-serif; }
|
||||||
#status { white-space: pre-wrap; }
|
label, input, textarea { display: block; margin-bottom: 0.5rem; }
|
||||||
|
input, textarea, button { font-family: "Public Sans", sans-serif; }
|
||||||
|
input, textarea { background: #222; color: #eee; }
|
||||||
|
#status, #key-status { white-space: pre-wrap; }
|
||||||
.state { color: #ff0; }
|
.state { color: #ff0; }
|
||||||
.received { color: #0ff; }
|
.received { color: #0ff; }
|
||||||
.error { color: #f00; }
|
.error { color: #f00; }
|
||||||
|
|
@ -14,7 +17,23 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>MTP WebTransport Client</h1>
|
<h1>MTP WebTransport Client</h1>
|
||||||
<div id="status">Initializing...</div>
|
<label for="server-url">Server URL</label>
|
||||||
|
<input id="server-url" value="https://127.0.0.1:8080" />
|
||||||
|
|
||||||
|
<label for="host-public-key">Host public key bundle hex</label>
|
||||||
|
<textarea id="host-public-key" placeholder="Paste PublicKeyBundle bytes as hex"></textarea>
|
||||||
|
|
||||||
|
<label for="client-public-key">Generated client public key bundle hex</label>
|
||||||
|
<textarea id="client-public-key" readonly></textarea>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="generate-keypair" type="button">Generate keypair</button>
|
||||||
|
<button id="connect" type="button" disabled>Connect</button>
|
||||||
|
<button id="clear-keys" type="button">Clear saved keys</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="key-status">Initializing...</div>
|
||||||
|
<div id="status"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
0
example-usage/web-client/public/.gitkeep
Normal file
0
example-usage/web-client/public/.gitkeep
Normal file
|
|
@ -2,15 +2,34 @@ import init, {
|
||||||
WasmClient,
|
WasmClient,
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
WasmEd25519Signer,
|
WasmKeyring,
|
||||||
ed25519_generate,
|
ed25519_generate,
|
||||||
keyring_from_ed25519,
|
keyring_from_ed25519,
|
||||||
build_demo_message,
|
build_demo_message,
|
||||||
|
format_frame,
|
||||||
} from "mtp-wasm";
|
} from "mtp-wasm";
|
||||||
|
|
||||||
const STATUS = document.getElementById("status")!;
|
const STATUS = document.getElementById("status")!;
|
||||||
|
const KEY_STATUS = document.getElementById("key-status")!;
|
||||||
|
const SERVER_URL = document.getElementById("server-url") as HTMLInputElement;
|
||||||
|
const HOST_PUBLIC_KEY = document.getElementById("host-public-key") as HTMLTextAreaElement;
|
||||||
|
const CLIENT_PUBLIC_KEY = document.getElementById("client-public-key") as HTMLTextAreaElement;
|
||||||
|
const GENERATE_KEYPAIR = document.getElementById("generate-keypair") as HTMLButtonElement;
|
||||||
|
const CONNECT = document.getElementById("connect") as HTMLButtonElement;
|
||||||
|
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
||||||
|
|
||||||
const STORAGE_KEY = "mtp-web-client-keys";
|
const STORAGE_KEY = "mtp-web-client-keys";
|
||||||
|
|
||||||
|
type SavedKeys = {
|
||||||
|
clientId: string | null;
|
||||||
|
keyring: number[];
|
||||||
|
hostPublicKey?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let keyringBytes: Uint8Array | null = null;
|
||||||
|
let clientId: bigint | null = null;
|
||||||
|
let devCertHash = "";
|
||||||
|
|
||||||
function log(msg: string, cls = "") {
|
function log(msg: string, cls = "") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
line.textContent = msg;
|
line.textContent = msg;
|
||||||
|
|
@ -18,28 +37,103 @@ function log(msg: string, cls = "") {
|
||||||
STATUS.appendChild(line);
|
STATUS.appendChild(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveKeys(clientId: bigint, keyringBytes: Uint8Array) {
|
function setKeyStatus(msg: string) {
|
||||||
const data = {
|
KEY_STATUS.textContent = msg;
|
||||||
clientId: clientId.toString(),
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(value: string): Uint8Array {
|
||||||
|
const hex = value.replace(/[^0-9a-fA-F]/g, "");
|
||||||
|
if (hex.length === 0) throw new Error("host public key is required");
|
||||||
|
if (hex.length % 2 !== 0) throw new Error("host public key hex has an odd length");
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveKeys() {
|
||||||
|
if (!keyringBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostPublicKey: number[] | undefined;
|
||||||
|
try {
|
||||||
|
hostPublicKey = Array.from(hexToBytes(HOST_PUBLIC_KEY.value));
|
||||||
|
} catch {
|
||||||
|
hostPublicKey = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SavedKeys = {
|
||||||
|
clientId: clientId?.toString() ?? null,
|
||||||
keyring: Array.from(keyringBytes),
|
keyring: Array.from(keyringBytes),
|
||||||
|
hostPublicKey,
|
||||||
};
|
};
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadKeys(): { clientId: bigint; keyringBytes: Uint8Array } | null {
|
function loadKeys() {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) {
|
||||||
const data = JSON.parse(raw);
|
setKeyStatus("No client keypair generated yet.");
|
||||||
return {
|
return;
|
||||||
clientId: BigInt(data.clientId),
|
}
|
||||||
keyringBytes: new Uint8Array(data.keyring),
|
|
||||||
};
|
const data = JSON.parse(raw) as SavedKeys;
|
||||||
|
keyringBytes = new Uint8Array(data.keyring);
|
||||||
|
clientId = data.clientId ? BigInt(data.clientId) : null;
|
||||||
|
CLIENT_PUBLIC_KEY.value = publicKeyHexFromKeyring(keyringBytes);
|
||||||
|
if (data.hostPublicKey) {
|
||||||
|
HOST_PUBLIC_KEY.value = bytesToHex(new Uint8Array(data.hostPublicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
setKeyStatus(
|
||||||
|
clientId
|
||||||
|
? `Loaded saved client keypair for client ${clientId}.`
|
||||||
|
: "Loaded generated client keypair. Not registered yet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHostPublicKey() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/host_public_key_bundle.hex", { cache: "no-store" });
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const hostPublicKey = (await response.text()).trim();
|
||||||
|
if (!hostPublicKey) return;
|
||||||
|
|
||||||
|
HOST_PUBLIC_KEY.value = hostPublicKey;
|
||||||
|
saveKeys();
|
||||||
|
log(`Loaded host public key bundle (${hostPublicKey.length / 2} bytes).`);
|
||||||
|
} catch {
|
||||||
|
// Manual paste still works when the server has not exported the file yet.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevCertHash() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/mtp_dev_cert_hash.txt", { cache: "no-store" });
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
devCertHash = (await response.text()).trim();
|
||||||
|
if (devCertHash) {
|
||||||
|
log(`Loaded WebTransport certificate hash: ${devCertHash}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
devCertHash = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initWasm() {
|
async function initWasm() {
|
||||||
log("Loading WASM module...");
|
log("Loading WASM module...");
|
||||||
await init();
|
await init();
|
||||||
log(`WASM loaded. WebTransport supported: ${WasmClient.is_supported()}`);
|
log(`WASM loaded. WebTransport supported: ${WasmClient.is_supported()}`);
|
||||||
|
CONNECT.disabled = !WasmClient.is_supported();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClient(): WasmClient {
|
function createClient(): WasmClient {
|
||||||
|
|
@ -47,11 +141,11 @@ function createClient(): WasmClient {
|
||||||
(state: number) =>
|
(state: number) =>
|
||||||
log(`[state] ${ConnectionState[state] ?? state}`, "state"),
|
log(`[state] ${ConnectionState[state] ?? state}`, "state"),
|
||||||
(data: Uint8Array) => {
|
(data: Uint8Array) => {
|
||||||
const decoder = new TextDecoder();
|
try {
|
||||||
log(
|
log(`Received: ${format_frame(data)}`, "received");
|
||||||
`[message] ${data.length} bytes: ${decoder.decode(data)}`,
|
} catch (e) {
|
||||||
"received",
|
log(`[message parse error] ${e}`, "error");
|
||||||
);
|
}
|
||||||
},
|
},
|
||||||
(err: any) => log(`[error] ${err}`, "error"),
|
(err: any) => log(`[error] ${err}`, "error"),
|
||||||
);
|
);
|
||||||
|
|
@ -65,55 +159,113 @@ function generateKeyringBytes(): Uint8Array {
|
||||||
return keyring_from_ed25519(sk, pk);
|
return keyring_from_ed25519(sk, pk);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
function publicKeyHexFromKeyring(bytes: Uint8Array): string {
|
||||||
await initWasm();
|
const keyring = WasmKeyring.from_bytes(bytes);
|
||||||
|
const publicBundle = keyring.public_key_bundle();
|
||||||
|
const publicHex = bytesToHex(publicBundle.to_bytes());
|
||||||
|
publicBundle.free();
|
||||||
|
keyring.free();
|
||||||
|
return publicHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
STATUS.textContent = "";
|
||||||
|
|
||||||
if (!WasmClient.is_supported()) {
|
if (!WasmClient.is_supported()) {
|
||||||
log("WebTransport is not supported in this browser.", "error");
|
log("WebTransport is not supported in this browser.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!keyringBytes) {
|
||||||
const serverUrl = "https://127.0.0.1:8080";
|
log("Generate a client keypair first.", "error");
|
||||||
const saved = loadKeys();
|
return;
|
||||||
|
|
||||||
const client = createClient();
|
|
||||||
const config = new ConnectionConfig(serverUrl);
|
|
||||||
|
|
||||||
let clientId: bigint;
|
|
||||||
let keyringBytes: Uint8Array;
|
|
||||||
|
|
||||||
if (saved) {
|
|
||||||
log(`Found saved client keys (ID: ${saved.clientId})`);
|
|
||||||
const hostPk = new Uint8Array(0);
|
|
||||||
clientId = await client.auth_connect(
|
|
||||||
config,
|
|
||||||
hostPk,
|
|
||||||
saved.keyringBytes,
|
|
||||||
saved.clientId,
|
|
||||||
);
|
|
||||||
log(`Authenticated as client ${clientId}`);
|
|
||||||
keyringBytes = saved.keyringBytes;
|
|
||||||
} else {
|
|
||||||
log("No saved keys: registering new client...");
|
|
||||||
const hostPk = new Uint8Array(0);
|
|
||||||
keyringBytes = generateKeyringBytes();
|
|
||||||
clientId = await client.auth_register(config, hostPk, keyringBytes);
|
|
||||||
log(`Registered with ID: ${clientId}`);
|
|
||||||
saveKeys(clientId, keyringBytes);
|
|
||||||
log("Saved client keys to localStorage");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config.free();
|
const hostPk = hexToBytes(HOST_PUBLIC_KEY.value);
|
||||||
|
await loadDevCertHash();
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
const serverUrl = SERVER_URL.value.trim();
|
||||||
|
const config = new ConnectionConfig(serverUrl);
|
||||||
|
if (devCertHash) {
|
||||||
|
log(`Pinning WebTransport certificate hash: sha-256:${devCertHash}`);
|
||||||
|
config.server_certificate_hashes = [`sha-256:${devCertHash}`];
|
||||||
|
} else {
|
||||||
|
log("No WebTransport certificate hash loaded; relying on browser trust store.", "state");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let activeClientId: bigint;
|
||||||
|
if (clientId !== null) {
|
||||||
|
log(`Using saved client ID ${clientId}...`);
|
||||||
|
activeClientId = await client.auth_connect(
|
||||||
|
config,
|
||||||
|
hostPk,
|
||||||
|
keyringBytes,
|
||||||
|
clientId,
|
||||||
|
);
|
||||||
|
log(`Authenticated as client ${activeClientId}`);
|
||||||
|
} else {
|
||||||
|
log("Registering generated client keypair...");
|
||||||
|
activeClientId = await client.auth_register(config, hostPk, keyringBytes);
|
||||||
|
clientId = activeClientId;
|
||||||
|
saveKeys();
|
||||||
|
log(`Registered with ID: ${activeClientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
log("\nSending demo message...");
|
log("\nSending demo message...");
|
||||||
const frame = build_demo_message(clientId, keyringBytes);
|
const frame = build_demo_message(activeClientId, keyringBytes, hostPk);
|
||||||
|
log(`Sending: ${format_frame(frame)}`, "state");
|
||||||
await client.send(frame);
|
await client.send(frame);
|
||||||
log(`Sent ${frame.length} bytes`);
|
log(`Sent ${frame.length} bytes`);
|
||||||
|
|
||||||
log("\nClient running. Waiting for incoming messages...");
|
log("\nClient running. Waiting for incoming messages...");
|
||||||
|
} finally {
|
||||||
|
config.free();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch((e) => {
|
GENERATE_KEYPAIR.addEventListener("click", () => {
|
||||||
|
try {
|
||||||
|
keyringBytes = generateKeyringBytes();
|
||||||
|
clientId = null;
|
||||||
|
saveKeys();
|
||||||
|
CLIENT_PUBLIC_KEY.value = publicKeyHexFromKeyring(keyringBytes);
|
||||||
|
setKeyStatus("Generated client keypair. Not registered yet.");
|
||||||
|
log("Generated and saved a new client keypair.");
|
||||||
|
} catch (e) {
|
||||||
|
log(`Key generation failed: ${e}`, "error");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
CONNECT.addEventListener("click", () => {
|
||||||
|
connect().catch((e) => {
|
||||||
|
log(`Fatal error: ${e}`, "error");
|
||||||
|
log(
|
||||||
|
`[fatal context] clientId=${clientId?.toString() ?? "unregistered"}, server=${SERVER_URL.value.trim()}, hostPkChars=${HOST_PUBLIC_KEY.value.replace(/[^0-9a-fA-F]/g, "").length}, keyringBytes=${keyringBytes?.length ?? 0}, certHash=${devCertHash || "none"}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
CLEAR_KEYS.addEventListener("click", () => {
|
||||||
|
keyringBytes = null;
|
||||||
|
clientId = null;
|
||||||
|
CLIENT_PUBLIC_KEY.value = "";
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setKeyStatus("No client keypair generated yet.");
|
||||||
|
log("Cleared saved client keys.");
|
||||||
|
});
|
||||||
|
|
||||||
|
HOST_PUBLIC_KEY.addEventListener("change", saveKeys);
|
||||||
|
|
||||||
|
initWasm()
|
||||||
|
.then(() => {
|
||||||
|
loadKeys();
|
||||||
|
return Promise.all([loadHostPublicKey(), loadDevCertHash()]);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
log(`Fatal error: ${e}`, "error");
|
log(`Fatal error: ${e}`, "error");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,12 @@ export function buildAuthResponse(
|
||||||
return parse_auth_response(response);
|
return parse_auth_response(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDemoMessage(clientId: bigint, keyringBytes: Uint8Array): Uint8Array {
|
export function buildDemoMessage(
|
||||||
return build_demo_message(clientId, keyringBytes);
|
clientId: bigint,
|
||||||
|
keyringBytes: Uint8Array,
|
||||||
|
hostBundle: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
return build_demo_message(clientId, keyringBytes, hostBundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPingFrame(
|
export function buildPingFrame(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
const devCertDir = path.resolve(__dirname, '../dev-cert');
|
||||||
|
const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem');
|
||||||
|
const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem');
|
||||||
|
|
||||||
|
const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|
@ -8,6 +15,12 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
https: hasDevCert
|
||||||
|
? {
|
||||||
|
cert: fs.readFileSync(certPath),
|
||||||
|
key: fs.readFileSync(keyPath),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
fs: {
|
fs: {
|
||||||
allow: ['.', path.resolve(__dirname, '../../wasm/pkg')],
|
allow: ['.', path.resolve(__dirname, '../../wasm/pkg')],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
62
flake.nix
62
flake.nix
|
|
@ -7,16 +7,14 @@
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs = {
|
||||||
{
|
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system: let
|
||||||
let
|
|
||||||
overlays = [rust-overlay.overlays.default];
|
overlays = [rust-overlay.overlays.default];
|
||||||
pkgs = import nixpkgs {inherit system overlays;};
|
pkgs = import nixpkgs {inherit system overlays;};
|
||||||
|
|
||||||
|
|
@ -28,9 +26,9 @@
|
||||||
];
|
];
|
||||||
targets = ["wasm32-unknown-unknown"];
|
targets = ["wasm32-unknown-unknown"];
|
||||||
};
|
};
|
||||||
in
|
in {
|
||||||
{
|
devShells = {
|
||||||
devShells.default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
name = "mtp-dev";
|
name = "mtp-dev";
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
|
|
@ -43,14 +41,64 @@
|
||||||
MTP_TYPE_MAPS = "${toString ./example-usage/type-maps.yaml}";
|
MTP_TYPE_MAPS = "${toString ./example-usage/type-maps.yaml}";
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||||
|
cert_dir="$repo_root/example-usage/dev-cert"
|
||||||
|
public_dir="$repo_root/example-usage/web-client/public"
|
||||||
|
cert_key="$cert_dir/key.pem"
|
||||||
|
cert_pem="$cert_dir/cert.pem"
|
||||||
|
cert_hash="$cert_dir/sha256.txt"
|
||||||
|
|
||||||
|
mkdir -p "$cert_dir"
|
||||||
|
mkdir -p "$public_dir"
|
||||||
|
|
||||||
|
if [ ! -f "$cert_key" ] || [ ! -f "$cert_pem" ]; then
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$cert_key"
|
||||||
|
openssl req -new -x509 \
|
||||||
|
-sha256 \
|
||||||
|
-key "$cert_key" \
|
||||||
|
-out "$cert_pem" \
|
||||||
|
-days 13 \
|
||||||
|
-subj "/CN=localhost" \
|
||||||
|
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
|
||||||
|
-addext "basicConstraints=critical,CA:FALSE" \
|
||||||
|
-addext "keyUsage=critical,digitalSignature" \
|
||||||
|
-addext "extendedKeyUsage=serverAuth"
|
||||||
|
cert_status="generated"
|
||||||
|
else
|
||||||
|
cert_status="cached"
|
||||||
|
fi
|
||||||
|
|
||||||
|
openssl x509 -in "$cert_pem" -outform der \
|
||||||
|
| openssl dgst -sha256 -binary \
|
||||||
|
| od -An -tx1 -v \
|
||||||
|
| tr -d ' \n' > "$cert_hash"
|
||||||
|
cp "$cert_hash" "$public_dir/mtp_dev_cert_hash.txt"
|
||||||
|
|
||||||
|
export MTP_DEV_CERT="$cert_pem"
|
||||||
|
export MTP_DEV_KEY="$cert_key"
|
||||||
|
export MTP_DEV_CERT_HASH="$(cat "$cert_hash")"
|
||||||
|
|
||||||
echo "MTP dev shell"
|
echo "MTP dev shell"
|
||||||
echo " rustc : $(rustc --version)"
|
echo " rustc : $(rustc --version)"
|
||||||
echo " cargo : $(cargo --version)"
|
echo " cargo : $(cargo --version)"
|
||||||
echo " wasm-pack : $(wasm-pack --version 2>/dev/null || echo 'not found')"
|
echo " wasm-pack : $(wasm-pack --version 2>/dev/null || echo 'not found')"
|
||||||
echo " targets: $(rustc --print target-list | grep wasm32 | tr '\n' ' ')"
|
echo " targets: $(rustc --print target-list | grep wasm32 | tr '\n' ' ')"
|
||||||
echo " MTP_TYPE_MAPS = $MTP_TYPE_MAPS"
|
echo " MTP_TYPE_MAPS = $MTP_TYPE_MAPS"
|
||||||
|
echo " dev cert: $MTP_DEV_CERT ($cert_status)"
|
||||||
|
echo " cert sha256: $MTP_DEV_CERT_HASH"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
autoStart = pkgs.mkShell {
|
||||||
|
name = "autoStart";
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
mprocs
|
||||||
|
];
|
||||||
|
shellHook = ''
|
||||||
|
nix develop --command bash -c "mprocs 'cd wasm && wasm-pack build --target web --out-dir pkg && cd ../example-usage/web-client && bun dev' 'cargo b && cd example-usage && cargo r --bin server' 'cd example-usage && cargo r --bin client'"
|
||||||
|
exit
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# Ad-hoc WASM build using wasm-pack
|
# Ad-hoc WASM build using wasm-pack
|
||||||
apps.wasm-build = {
|
apps.wasm-build = {
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,9 @@ impl MTPHost {
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let (assigned_id, client_bundle) =
|
let (assigned_id, client_bundle, response_type) = if msg.get_type()
|
||||||
if msg.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) {
|
== mtp_codec::CommunicationType::Identification.to_id(&tm)
|
||||||
|
{
|
||||||
// LOGIN
|
// LOGIN
|
||||||
let cid = match msg.get_data(DataType::Id.to_id(&tm)) {
|
let cid = match msg.get_data(DataType::Id.to_id(&tm)) {
|
||||||
DataValue::UnsignedNumber(n) => *n as u64,
|
DataValue::UnsignedNumber(n) => *n as u64,
|
||||||
|
|
@ -238,7 +239,11 @@ impl MTPHost {
|
||||||
}
|
}
|
||||||
/* ===== End Signature ===== */
|
/* ===== End Signature ===== */
|
||||||
|
|
||||||
(cid, bundle)
|
(
|
||||||
|
cid,
|
||||||
|
bundle,
|
||||||
|
mtp_codec::CommunicationType::IdentificationResponse,
|
||||||
|
)
|
||||||
} else if msg.get_type() == mtp_codec::CommunicationType::Register.to_id(&tm) {
|
} else if msg.get_type() == mtp_codec::CommunicationType::Register.to_id(&tm) {
|
||||||
// REGISTER
|
// REGISTER
|
||||||
let bundle = match msg.get_data(DataType::PublicKeys.to_id(&tm)) {
|
let bundle = match msg.get_data(DataType::PublicKeys.to_id(&tm)) {
|
||||||
|
|
@ -285,7 +290,11 @@ impl MTPHost {
|
||||||
/* ===== End Signature ===== */
|
/* ===== End Signature ===== */
|
||||||
|
|
||||||
let new_id = (self.config.complete_register)(bundle.clone());
|
let new_id = (self.config.complete_register)(bundle.clone());
|
||||||
(new_id, bundle)
|
(
|
||||||
|
new_id,
|
||||||
|
bundle,
|
||||||
|
mtp_codec::CommunicationType::RegisterResponse,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
sender.close();
|
sender.close();
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -305,8 +314,7 @@ impl MTPHost {
|
||||||
/* ===== Signature ===== */
|
/* ===== Signature ===== */
|
||||||
let host_sig = host_signer.sign(&host_sig_payload).ok()?;
|
let host_sig = host_signer.sign(&host_sig_payload).ok()?;
|
||||||
|
|
||||||
let mut response =
|
let mut response = CommunicationValue::new(response_type)
|
||||||
CommunicationValue::new(mtp_codec::CommunicationType::IdentificationResponse)
|
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
|
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::ClientNonce,
|
DataType::ClientNonce,
|
||||||
|
|
@ -336,6 +344,7 @@ impl MTPHost {
|
||||||
/* ===== End Signature ===== */
|
/* ===== End Signature ===== */
|
||||||
|
|
||||||
sender.send(&response).await.ok()?;
|
sender.send(&response).await.ok()?;
|
||||||
|
sender.finish_stream().await.ok()?;
|
||||||
|
|
||||||
// 3. Version negotiation
|
// 3. Version negotiation
|
||||||
let negotiated = self.registry.negotiate(&[client_version])?;
|
let negotiated = self.registry.negotiate(&[client_version])?;
|
||||||
|
|
@ -380,7 +389,10 @@ mod tests {
|
||||||
mtp_codec::CommunicationType::Identification,
|
mtp_codec::CommunicationType::Identification,
|
||||||
&tm,
|
&tm,
|
||||||
)
|
)
|
||||||
.add_data(DataType::Version.to_id(&tm), DataValue::Str("2.0".to_string()));
|
.add_data(
|
||||||
|
DataType::Version.to_id(&tm),
|
||||||
|
DataValue::Str("2.0".to_string()),
|
||||||
|
);
|
||||||
let version = extract_version(&msg);
|
let version = extract_version(&msg);
|
||||||
assert_eq!(version, Some(Version(2, 0)));
|
assert_eq!(version, Some(Version(2, 0)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
edition = "2024"
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
pub use mtp_common as common;
|
|
||||||
pub use mtp_type_map as type_map;
|
|
||||||
pub use mtp_codec as codec;
|
pub use mtp_codec as codec;
|
||||||
|
pub use mtp_common as common;
|
||||||
pub use mtp_transport as transport;
|
pub use mtp_transport as transport;
|
||||||
|
pub use mtp_type_map as type_map;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub use mtp_crypto as crypto;
|
pub use mtp_crypto as crypto;
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ enum ReceivedFrame {
|
||||||
|
|
||||||
pub struct Sender {
|
pub struct Sender {
|
||||||
send_guard: Mutex<()>,
|
send_guard: Mutex<()>,
|
||||||
stream_guard: Mutex<Option<wtransport::SendStream>>,
|
stream_guard: Arc<Mutex<Option<wtransport::SendStream>>>,
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<Policy>,
|
||||||
|
|
@ -71,7 +71,7 @@ impl Sender {
|
||||||
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
send_guard: Mutex::new(()),
|
send_guard: Mutex::new(()),
|
||||||
stream_guard: Mutex::new(None),
|
stream_guard: Arc::new(Mutex::new(None)),
|
||||||
handle,
|
handle,
|
||||||
connection,
|
connection,
|
||||||
policy,
|
policy,
|
||||||
|
|
@ -275,6 +275,18 @@ impl Sender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn finish_stream(&self) -> Result<(), CommunicationError> {
|
||||||
|
let _send_lock = self.send_guard.lock().await;
|
||||||
|
let mut stream_opt = self.stream_guard.lock().await;
|
||||||
|
if let Some(mut stream) = stream_opt.take() {
|
||||||
|
timeout(self.policy.write_timeout, stream.finish())
|
||||||
|
.await
|
||||||
|
.map_err(|_| CommunicationError::StreamError)?
|
||||||
|
.map_err(|_| CommunicationError::StreamError)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle(&self) -> &Arc<ConnectionHandle> {
|
pub fn handle(&self) -> &Arc<ConnectionHandle> {
|
||||||
&self.handle
|
&self.handle
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +295,7 @@ impl Sender {
|
||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let handle = self.handle.clone();
|
let handle = self.handle.clone();
|
||||||
let policy = self.policy.clone();
|
let policy = self.policy.clone();
|
||||||
|
let stream_guard = self.stream_guard.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
|
if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
|
||||||
|
|
@ -290,6 +303,14 @@ impl Sender {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(mut stream) = stream_guard.lock().await.take() {
|
||||||
|
match timeout(policy.write_timeout, stream.finish()).await {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => log::warn!("[Sender] persistent stream finish failed: {e}"),
|
||||||
|
Err(_) => log::warn!("[Sender] persistent stream finish timed out"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = Self::send_close_frame(&connection, &policy).await;
|
let _ = Self::send_close_frame(&connection, &policy).await;
|
||||||
|
|
||||||
handle.close(Some(CommunicationError::StreamClosed));
|
handle.close(Some(CommunicationError::StreamClosed));
|
||||||
|
|
@ -323,6 +344,18 @@ pub struct Receiver {
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for Receiver {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// The accept loop holds clones of the connection and the shared
|
||||||
|
// ConnectionHandle. Without this, dropping a Receiver without first
|
||||||
|
// closing the connection would leave that task running forever. Abort
|
||||||
|
// it directly rather than closing the shared handle, so a still-live
|
||||||
|
// Sender on the same connection is unaffected. abort() is a no-op if
|
||||||
|
// the task already finished (e.g. the connection was closed).
|
||||||
|
self._accept_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Receiver {
|
impl Receiver {
|
||||||
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
||||||
let (tx, rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
|
let (tx, rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ impl ConnectionHandle {
|
||||||
if self.is_closed() {
|
if self.is_closed() {
|
||||||
return rx.borrow().clone();
|
return rx.borrow().clone();
|
||||||
}
|
}
|
||||||
let _ = rx.changed().await.ok()?;
|
rx.changed().await.ok()?;
|
||||||
rx.borrow().clone()
|
rx.borrow().clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,22 @@ impl Host {
|
||||||
pub fn local_addr(&self) -> std::net::SocketAddr {
|
pub fn local_addr(&self) -> std::net::SocketAddr {
|
||||||
self.local_addr
|
self.local_addr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop accepting new connections. Already-accepted connections run on their
|
||||||
|
/// own spawned tasks and are not affected.
|
||||||
|
pub fn shutdown(&mut self) {
|
||||||
|
self._task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Host {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// The accept loop runs forever on its own task; dropping the Host
|
||||||
|
// JoinHandle would only detach it. Abort it so dropping the Host
|
||||||
|
// actually stops accepting new connections. Per-connection handler
|
||||||
|
// tasks are spawned independently and keep running.
|
||||||
|
self._task.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn host(
|
pub async fn host(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
use mtp_transport::{Policy, host, connect};
|
use mtp_transport::{Policy, connect, host};
|
||||||
|
|
||||||
fn generate_self_signed_cert() -> (Vec<u8>, Vec<u8>) {
|
fn generate_self_signed_cert() -> (Vec<u8>, Vec<u8>) {
|
||||||
let key_pair = rcgen::KeyPair::generate().unwrap();
|
let key_pair = rcgen::KeyPair::generate().unwrap();
|
||||||
|
|
@ -44,15 +44,15 @@ async fn test_send_receive_roundtrip() {
|
||||||
let addr = h.local_addr();
|
let addr = h.local_addr();
|
||||||
|
|
||||||
let url = format!("https://127.0.0.1:{}", addr.port());
|
let url = format!("https://127.0.0.1:{}", addr.port());
|
||||||
let (client_tx, client_rx) =
|
let (client_tx, client_rx) = connect(&url, Some(cert_pem), Policy::default())
|
||||||
connect(&url, Some(cert_pem), Policy::default()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Accept on host side
|
// Accept on host side
|
||||||
let (host_tx, host_rx) = h.next().await.unwrap();
|
let (host_tx, host_rx) = h.next().await.unwrap();
|
||||||
|
|
||||||
// Client sends a simple message
|
// Client sends a simple message
|
||||||
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Ping)
|
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Ping).add_data(
|
||||||
.add_data(
|
|
||||||
mtp_codec::DataTypeId(6),
|
mtp_codec::DataTypeId(6),
|
||||||
mtp_codec::DataValue::UnsignedNumber(42),
|
mtp_codec::DataValue::UnsignedNumber(42),
|
||||||
);
|
);
|
||||||
|
|
@ -65,8 +65,7 @@ async fn test_send_receive_roundtrip() {
|
||||||
assert_eq!(val, mtp_codec::DataValue::UnsignedNumber(42));
|
assert_eq!(val, mtp_codec::DataValue::UnsignedNumber(42));
|
||||||
|
|
||||||
// Host sends a response
|
// Host sends a response
|
||||||
let resp = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Pong)
|
let resp = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Pong).add_data(
|
||||||
.add_data(
|
|
||||||
mtp_codec::DataTypeId(6),
|
mtp_codec::DataTypeId(6),
|
||||||
mtp_codec::DataValue::UnsignedNumber(99),
|
mtp_codec::DataValue::UnsignedNumber(99),
|
||||||
);
|
);
|
||||||
|
|
@ -74,7 +73,10 @@ async fn test_send_receive_roundtrip() {
|
||||||
|
|
||||||
// Client receives it
|
// Client receives it
|
||||||
let client_received = client_rx.receive().await.unwrap();
|
let client_received = client_rx.receive().await.unwrap();
|
||||||
assert_eq!(client_received.get_type(), mtp_codec::CommunicationTypeId(20)); // Pong
|
assert_eq!(
|
||||||
|
client_received.get_type(),
|
||||||
|
mtp_codec::CommunicationTypeId(20)
|
||||||
|
); // Pong
|
||||||
let client_val = client_received.get_data(mtp_codec::DataTypeId(6)).clone();
|
let client_val = client_received.get_data(mtp_codec::DataTypeId(6)).clone();
|
||||||
assert_eq!(client_val, mtp_codec::DataValue::UnsignedNumber(99));
|
assert_eq!(client_val, mtp_codec::DataValue::UnsignedNumber(99));
|
||||||
|
|
||||||
|
|
@ -98,15 +100,15 @@ async fn test_concurrent_messages() {
|
||||||
let addr = h.local_addr();
|
let addr = h.local_addr();
|
||||||
|
|
||||||
let url = format!("https://127.0.0.1:{}", addr.port());
|
let url = format!("https://127.0.0.1:{}", addr.port());
|
||||||
let (client_tx, _client_rx) =
|
let (client_tx, _client_rx) = connect(&url, Some(cert_pem), Policy::default())
|
||||||
connect(&url, Some(cert_pem), Policy::default()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (_host_tx, host_rx) = h.next().await.unwrap();
|
let (_host_tx, host_rx) = h.next().await.unwrap();
|
||||||
|
|
||||||
// Send 5 messages in sequence
|
// Send 5 messages in sequence
|
||||||
for i in 0..5u128 {
|
for i in 0..5u128 {
|
||||||
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Ping)
|
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Ping).add_data(
|
||||||
.add_data(
|
|
||||||
mtp_codec::DataTypeId(6),
|
mtp_codec::DataTypeId(6),
|
||||||
mtp_codec::DataValue::UnsignedNumber(i),
|
mtp_codec::DataValue::UnsignedNumber(i),
|
||||||
);
|
);
|
||||||
|
|
@ -122,8 +124,7 @@ async fn test_concurrent_messages() {
|
||||||
|
|
||||||
// Send 3 responses back
|
// Send 3 responses back
|
||||||
for i in 0..3u128 {
|
for i in 0..3u128 {
|
||||||
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Pong)
|
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Pong).add_data(
|
||||||
.add_data(
|
|
||||||
mtp_codec::DataTypeId(6),
|
mtp_codec::DataTypeId(6),
|
||||||
mtp_codec::DataValue::UnsignedNumber(i * 10),
|
mtp_codec::DataValue::UnsignedNumber(i * 10),
|
||||||
);
|
);
|
||||||
|
|
@ -154,8 +155,9 @@ async fn test_close_detection() {
|
||||||
let addr = h.local_addr();
|
let addr = h.local_addr();
|
||||||
|
|
||||||
let url = format!("https://127.0.0.1:{}", addr.port());
|
let url = format!("https://127.0.0.1:{}", addr.port());
|
||||||
let (client_tx, _client_rx) =
|
let (client_tx, _client_rx) = connect(&url, Some(cert_pem), Policy::default())
|
||||||
connect(&url, Some(cert_pem), Policy::default()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (_host_tx, host_rx) = h.next().await.unwrap();
|
let (_host_tx, host_rx) = h.next().await.unwrap();
|
||||||
|
|
||||||
|
|
@ -172,3 +174,87 @@ async fn test_close_detection() {
|
||||||
let result = host_rx.receive().await;
|
let result = host_rx.receive().await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_host_shutdown_stops_accepting() {
|
||||||
|
let (cert_pem, key_pem) = generate_self_signed_cert();
|
||||||
|
let mut h = host(
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
0,
|
||||||
|
cert_pem.clone(),
|
||||||
|
key_pem,
|
||||||
|
Policy::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let addr = h.local_addr();
|
||||||
|
let url = format!("https://127.0.0.1:{}", addr.port());
|
||||||
|
|
||||||
|
// A connection succeeds while the host is accepting.
|
||||||
|
let (_c_tx, _c_rx) = connect(&url, Some(cert_pem.clone()), Policy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _accepted = h.next().await.unwrap();
|
||||||
|
|
||||||
|
// After shutdown the accept task is aborted and its endpoint is dropped, so
|
||||||
|
// new connections no longer succeed. Guard with a timeout so a hung connect
|
||||||
|
// still fails the assertion rather than blocking the test.
|
||||||
|
h.shutdown();
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
connect(&url, Some(cert_pem), Policy::default()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(_) | Ok(Err(_))),
|
||||||
|
"connect should not succeed after host shutdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_drop_receiver_keeps_sender_alive() {
|
||||||
|
let (cert_pem, key_pem) = generate_self_signed_cert();
|
||||||
|
let mut h = host(
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
0,
|
||||||
|
cert_pem.clone(),
|
||||||
|
key_pem,
|
||||||
|
Policy::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let addr = h.local_addr();
|
||||||
|
|
||||||
|
let url = format!("https://127.0.0.1:{}", addr.port());
|
||||||
|
let (client_tx, client_rx) = connect(&url, Some(cert_pem), Policy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (host_tx, host_rx) = h.next().await.unwrap();
|
||||||
|
|
||||||
|
// Client sends a message the host receives.
|
||||||
|
let msg = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Ping);
|
||||||
|
client_tx.send(&msg).await.unwrap();
|
||||||
|
let _ = host_rx.receive().await.unwrap();
|
||||||
|
|
||||||
|
// Dropping the host Receiver aborts only its accept task; the Sender shares
|
||||||
|
// the same connection and must keep working.
|
||||||
|
drop(host_rx);
|
||||||
|
|
||||||
|
let resp = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Pong).add_data(
|
||||||
|
mtp_codec::DataTypeId(6),
|
||||||
|
mtp_codec::DataValue::UnsignedNumber(7),
|
||||||
|
);
|
||||||
|
host_tx.send(&resp).await.unwrap();
|
||||||
|
|
||||||
|
let got = client_rx.receive().await.unwrap();
|
||||||
|
assert_eq!(got.get_type(), mtp_codec::CommunicationTypeId(20)); // Pong
|
||||||
|
assert_eq!(
|
||||||
|
got.get_data(mtp_codec::DataTypeId(6)).clone(),
|
||||||
|
mtp_codec::DataValue::UnsignedNumber(7)
|
||||||
|
);
|
||||||
|
|
||||||
|
client_tx.close();
|
||||||
|
host_tx.close();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ fn generate(config: &Config, multi_version: bool) -> String {
|
||||||
let (all_comm_names, all_data_names, sorted) = if multi_version {
|
let (all_comm_names, all_data_names, sorted) = if multi_version {
|
||||||
let mut all_comm_names: BTreeSet<&str> = BTreeSet::new();
|
let mut all_comm_names: BTreeSet<&str> = BTreeSet::new();
|
||||||
let mut all_data_names: BTreeSet<&str> = BTreeSet::new();
|
let mut all_data_names: BTreeSet<&str> = BTreeSet::new();
|
||||||
for (_version_key, tm) in &config.type_maps {
|
for tm in config.type_maps.values() {
|
||||||
for name in tm.communication_types.keys() {
|
for name in tm.communication_types.keys() {
|
||||||
all_comm_names.insert(name.as_str());
|
all_comm_names.insert(name.as_str());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
// try_new returns Result<Self, ()> deliberately: the only failure mode is "id
|
||||||
|
// is in the reserved range", which carries no extra information worth an error
|
||||||
|
// type. The unit error is the intended API.
|
||||||
|
#![allow(clippy::result_unit_err)]
|
||||||
|
|
||||||
pub const INTERNAL_COMM_RESERVED: std::ops::Range<u16> = 0..32;
|
pub const INTERNAL_COMM_RESERVED: std::ops::Range<u16> = 0..32;
|
||||||
pub const INTERNAL_DATA_RESERVED: std::ops::Range<u16> = 0..32;
|
pub const INTERNAL_DATA_RESERVED: std::ops::Range<u16> = 0..32;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
# Default to the wasm32 target when running cargo from inside this crate dir.
|
||||||
|
# The web_sys_unstable_apis cfg lives in the workspace-root .cargo/config.toml,
|
||||||
|
# scoped to [target.wasm32-unknown-unknown], so it applies here too (the root
|
||||||
|
# config is an ancestor) and to `-p mtp-wasm` builds invoked from the root.
|
||||||
[build]
|
[build]
|
||||||
target = "wasm32-unknown-unknown"
|
target = "wasm32-unknown-unknown"
|
||||||
|
|
||||||
[target.wasm32-unknown-unknown]
|
|
||||||
rustflags = ["--cfg=web_sys_unstable_apis"]
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,80 @@ use std::rc::Rc;
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use mtp_codec::{
|
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION};
|
||||||
CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION,
|
use mtp_type_map::CommunicationTypeId;
|
||||||
};
|
|
||||||
use mtp_type_map::{CommunicationTypeId, DataTypeId};
|
|
||||||
|
|
||||||
use mtp_crypto::SignatureScheme;
|
use mtp_crypto::SignatureScheme;
|
||||||
|
|
||||||
use crate::error::js_error;
|
use crate::error::js_error;
|
||||||
use crate::transport::WasmTransport;
|
use crate::transport::WasmTransport;
|
||||||
|
|
||||||
|
fn raw_frame_preview(bytes: &[u8]) -> String {
|
||||||
|
let shown = bytes.len().min(256);
|
||||||
|
let mut preview = hex::encode(&bytes[..shown]);
|
||||||
|
if bytes.len() > shown {
|
||||||
|
preview.push_str("...");
|
||||||
|
}
|
||||||
|
format!("{} bytes, hex={preview}", bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unexpected_response_type_error(
|
||||||
|
context: &str,
|
||||||
|
expected_type: CommunicationTypeId,
|
||||||
|
response_type: CommunicationTypeId,
|
||||||
|
response: &[u8],
|
||||||
|
parsed: &CommunicationValue,
|
||||||
|
) -> JsValue {
|
||||||
|
js_error(&format!(
|
||||||
|
"unexpected response type during {context}: expected {:?}, got {:?}; raw {}; parsed {}",
|
||||||
|
expected_type,
|
||||||
|
response_type,
|
||||||
|
raw_frame_preview(response),
|
||||||
|
parsed
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the host's authentication signature over the handshake response,
|
||||||
|
/// mirroring the native client (`client/src/lib.rs`). The signed payload is
|
||||||
|
/// `0x01 || id.to_be_bytes() || client_nonce.to_be_bytes() || host_nonce.to_be_bytes()`,
|
||||||
|
/// where `id` is the client id for login and the host-assigned id for register.
|
||||||
|
/// The Ed25519 signature is mandatory; the ML-DSA signature is verified only
|
||||||
|
/// when the host included one.
|
||||||
|
fn verify_host_signature(
|
||||||
|
resp: &CommunicationValue,
|
||||||
|
tm: &mtp_codec::TypeMap,
|
||||||
|
host_pk: &mtp_crypto::PublicKeyBundle,
|
||||||
|
id: u64,
|
||||||
|
client_nonce: u128,
|
||||||
|
) -> Result<(), JsValue> {
|
||||||
|
let host_new_nonce = match resp.get_data(DataType::Timestamp.to_id(tm)) {
|
||||||
|
DataValue::UnsignedNumber(n) => *n,
|
||||||
|
_ => return Err(js_error("missing host nonce")),
|
||||||
|
};
|
||||||
|
let host_sig = match resp.get_data(DataType::Signature.to_id(tm)) {
|
||||||
|
DataValue::Bytes(b) => b.clone(),
|
||||||
|
_ => return Err(js_error("missing host signature")),
|
||||||
|
};
|
||||||
|
let host_pq_sig = match resp.get_data(DataType::PqSignature.to_id(tm)) {
|
||||||
|
DataValue::Bytes(b) => b.clone(),
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
payload.push(0x01);
|
||||||
|
payload.extend_from_slice(&id.to_be_bytes());
|
||||||
|
payload.extend_from_slice(&client_nonce.to_be_bytes());
|
||||||
|
payload.extend_from_slice(&host_new_nonce.to_be_bytes());
|
||||||
|
|
||||||
|
mtp_crypto::verify_ed25519(&host_pk.sig_cl_public_key, &payload, &host_sig)
|
||||||
|
.map_err(|_| js_error("host signature invalid"))?;
|
||||||
|
if !host_pq_sig.is_empty() {
|
||||||
|
mtp_crypto::verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &host_pq_sig)
|
||||||
|
.map_err(|_| js_error("host PQ signature invalid"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ConnectionState {
|
pub enum ConnectionState {
|
||||||
|
|
@ -33,17 +97,27 @@ pub struct ConnectionConfig {
|
||||||
impl ConnectionConfig {
|
impl ConnectionConfig {
|
||||||
#[wasm_bindgen(constructor)]
|
#[wasm_bindgen(constructor)]
|
||||||
pub fn new(url: String) -> Self {
|
pub fn new(url: String) -> Self {
|
||||||
Self { url, server_certificate_hashes: None, client_id: 0 }
|
Self {
|
||||||
|
url,
|
||||||
|
server_certificate_hashes: None,
|
||||||
|
client_id: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(getter)]
|
#[wasm_bindgen(getter)]
|
||||||
pub fn url(&self) -> String { self.url.clone() }
|
pub fn url(&self) -> String {
|
||||||
|
self.url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(setter)]
|
#[wasm_bindgen(setter)]
|
||||||
pub fn set_client_id(&mut self, id: u64) { self.client_id = id; }
|
pub fn set_client_id(&mut self, id: u64) {
|
||||||
|
self.client_id = id;
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(getter)]
|
#[wasm_bindgen(getter)]
|
||||||
pub fn client_id(&self) -> u64 { self.client_id }
|
pub fn client_id(&self) -> u64 {
|
||||||
|
self.client_id
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(setter)]
|
#[wasm_bindgen(setter)]
|
||||||
pub fn set_server_certificate_hashes(&mut self, hashes: Vec<String>) {
|
pub fn set_server_certificate_hashes(&mut self, hashes: Vec<String>) {
|
||||||
|
|
@ -79,24 +153,29 @@ impl WasmClient {
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn is_supported() -> bool {
|
pub fn is_supported() -> bool {
|
||||||
js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport"))
|
js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")).unwrap_or(false)
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(getter)]
|
#[wasm_bindgen(getter)]
|
||||||
pub fn state(&self) -> u8 { self.state.get() as u8 }
|
pub fn state(&self) -> u8 {
|
||||||
|
self.state.get() as u8
|
||||||
|
}
|
||||||
|
|
||||||
/// Unauthenticated connect (sends basic Identification, enables receive loop).
|
/// Unauthenticated connect (sends basic Identification, enables receive loop).
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub async fn connect(&mut self, config: &ConnectionConfig) -> Result<(), JsValue> {
|
pub async fn connect(&mut self, config: &ConnectionConfig) -> Result<(), JsValue> {
|
||||||
self.set_state(ConnectionState::Connecting);
|
self.set_state(ConnectionState::Connecting);
|
||||||
let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
|
let transport =
|
||||||
|
WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
|
||||||
let inner = transport.inner().clone();
|
let inner = transport.inner().clone();
|
||||||
|
|
||||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
let ident = CommunicationValue::new(CommunicationType::Identification)
|
let ident = CommunicationValue::new(CommunicationType::Identification)
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
||||||
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(config.client_id as u128));
|
.add_typed_default(
|
||||||
|
DataType::Id,
|
||||||
|
DataValue::UnsignedNumber(config.client_id as u128),
|
||||||
|
);
|
||||||
let ident_bytes = ident
|
let ident_bytes = ident
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
||||||
|
|
@ -109,7 +188,9 @@ impl WasmClient {
|
||||||
let on_msg = self.on_message.clone();
|
let on_msg = self.on_message.clone();
|
||||||
let on_err = self.on_error.clone();
|
let on_err = self.on_error.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
WasmTransport::from_inner(inner).receive_loop(on_msg, on_err).await;
|
WasmTransport::from_inner(inner)
|
||||||
|
.receive_loop(on_msg, on_err)
|
||||||
|
.await;
|
||||||
state.set(ConnectionState::Disconnected);
|
state.set(ConnectionState::Disconnected);
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -127,19 +208,20 @@ impl WasmClient {
|
||||||
pub async fn auth_connect(
|
pub async fn auth_connect(
|
||||||
&mut self,
|
&mut self,
|
||||||
config: &ConnectionConfig,
|
config: &ConnectionConfig,
|
||||||
_host_public_key_bytes: &[u8],
|
host_public_key_bytes: &[u8],
|
||||||
keyring_bytes: &[u8],
|
keyring_bytes: &[u8],
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
) -> Result<u64, JsValue> {
|
) -> Result<u64, JsValue> {
|
||||||
self.set_state(ConnectionState::Connecting);
|
self.set_state(ConnectionState::Connecting);
|
||||||
|
|
||||||
|
let host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes)
|
||||||
|
.map_err(|e| js_error(&format!("invalid host public key: {}", e)))?;
|
||||||
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes)
|
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes)
|
||||||
.map_err(|e| js_error(&format!("invalid keyring: {}", e)))?;
|
.map_err(|e| js_error(&format!("invalid keyring: {}", e)))?;
|
||||||
|
|
||||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
let mut nonce_bytes = [0u8; 16];
|
let mut nonce_bytes = [0u8; 16];
|
||||||
getrandom::fill(&mut nonce_bytes)
|
getrandom::fill(&mut nonce_bytes).map_err(|_| js_error("rng failed"))?;
|
||||||
.map_err(|_| js_error("rng failed"))?;
|
|
||||||
let client_nonce = u128::from_be_bytes(nonce_bytes);
|
let client_nonce = u128::from_be_bytes(nonce_bytes);
|
||||||
|
|
||||||
// Build signature payload: version || client_id || client_nonce
|
// Build signature payload: version || client_id || client_nonce
|
||||||
|
|
@ -150,18 +232,23 @@ impl WasmClient {
|
||||||
|
|
||||||
let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
||||||
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
|
||||||
let signature = signer.sign(&sig_payload)
|
let signature = signer
|
||||||
|
.sign(&sig_payload)
|
||||||
.map_err(|e| js_error(&format!("signature failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("signature failed: {}", e)))?;
|
||||||
|
|
||||||
let frame = CommunicationValue::new(CommunicationType::Identification)
|
let frame = CommunicationValue::new(CommunicationType::Identification)
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
||||||
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128))
|
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128))
|
||||||
.add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(client_nonce))
|
.add_typed_default(
|
||||||
|
DataType::ClientNonce,
|
||||||
|
DataValue::UnsignedNumber(client_nonce),
|
||||||
|
)
|
||||||
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
|
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
||||||
|
|
||||||
let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
|
let transport =
|
||||||
|
WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
|
||||||
let inner = transport.inner().clone();
|
let inner = transport.inner().clone();
|
||||||
transport.send_frame(&frame).await?;
|
transport.send_frame(&frame).await?;
|
||||||
|
|
||||||
|
|
@ -170,26 +257,45 @@ impl WasmClient {
|
||||||
let resp_comm = CommunicationValue::from_bytes(&response)
|
let resp_comm = CommunicationValue::from_bytes(&response)
|
||||||
.map_err(|e| js_error(&format!("parse response: {}", e)))?;
|
.map_err(|e| js_error(&format!("parse response: {}", e)))?;
|
||||||
|
|
||||||
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
let resp_type = resp_comm.get_type();
|
let resp_type = resp_comm.get_type();
|
||||||
let expected_type = CommunicationTypeId(16); // IdentificationResponse
|
let expected_type = CommunicationType::IdentificationResponse.to_id(&tm);
|
||||||
if resp_type != expected_type {
|
if resp_type != expected_type {
|
||||||
return Err(js_error("unexpected response type"));
|
self.set_state(ConnectionState::Disconnected);
|
||||||
|
return Err(unexpected_response_type_error(
|
||||||
|
"auth_connect",
|
||||||
|
expected_type,
|
||||||
|
resp_type,
|
||||||
|
&response,
|
||||||
|
&resp_comm,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp_comm.get_data(DataTypeId(11)) != &DataValue::BoolTrue {
|
if resp_comm.get_data(DataType::Connected.to_id(&tm)) != &DataValue::BoolTrue {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
return Err(js_error("host rejected authentication"));
|
return Err(js_error("host rejected authentication"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify echoed nonce
|
// Verify echoed nonce
|
||||||
let echo_nonce = resp_comm.get_data(DataTypeId(7));
|
let echo_nonce = resp_comm.get_data(DataType::ClientNonce.to_id(&tm));
|
||||||
if *echo_nonce != DataValue::UnsignedNumber(client_nonce) {
|
if *echo_nonce != DataValue::UnsignedNumber(client_nonce) {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
return Err(js_error("nonce mismatch"));
|
return Err(js_error("nonce mismatch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the host's signature over the handshake (login: id is client_id).
|
||||||
|
if let Err(e) = verify_host_signature(&resp_comm, &tm, &host_pk, client_id, client_nonce) {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract assigned ID
|
// Extract assigned ID
|
||||||
let assigned_id = match resp_comm.get_data(DataTypeId(6)) {
|
let assigned_id = match resp_comm.get_data(DataType::Id.to_id(&tm)) {
|
||||||
DataValue::UnsignedNumber(n) => *n as u64,
|
DataValue::UnsignedNumber(n) => *n as u64,
|
||||||
_ => return Err(js_error("missing assigned ID")),
|
_ => {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
|
return Err(js_error("missing assigned ID"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.transport = Some(transport);
|
self.transport = Some(transport);
|
||||||
|
|
@ -199,7 +305,9 @@ impl WasmClient {
|
||||||
let on_msg = self.on_message.clone();
|
let on_msg = self.on_message.clone();
|
||||||
let on_err = self.on_error.clone();
|
let on_err = self.on_error.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
WasmTransport::from_inner(inner).receive_loop(on_msg, on_err).await;
|
WasmTransport::from_inner(inner)
|
||||||
|
.receive_loop(on_msg, on_err)
|
||||||
|
.await;
|
||||||
state.set(ConnectionState::Disconnected);
|
state.set(ConnectionState::Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -222,15 +330,14 @@ impl WasmClient {
|
||||||
) -> Result<u64, JsValue> {
|
) -> Result<u64, JsValue> {
|
||||||
self.set_state(ConnectionState::Connecting);
|
self.set_state(ConnectionState::Connecting);
|
||||||
|
|
||||||
let _host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes)
|
let host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes)
|
||||||
.map_err(|e| js_error(&format!("invalid host public key: {}", e)))?;
|
.map_err(|e| js_error(&format!("invalid host public key: {}", e)))?;
|
||||||
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes)
|
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes)
|
||||||
.map_err(|e| js_error(&format!("invalid keyring: {}", e)))?;
|
.map_err(|e| js_error(&format!("invalid keyring: {}", e)))?;
|
||||||
|
|
||||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
let mut nonce_bytes = [0u8; 16];
|
let mut nonce_bytes = [0u8; 16];
|
||||||
getrandom::fill(&mut nonce_bytes)
|
getrandom::fill(&mut nonce_bytes).map_err(|_| js_error("rng failed"))?;
|
||||||
.map_err(|_| js_error("rng failed"))?;
|
|
||||||
let client_nonce = u128::from_be_bytes(nonce_bytes);
|
let client_nonce = u128::from_be_bytes(nonce_bytes);
|
||||||
|
|
||||||
let pk_bytes = keyring.public_key_bundle().as_bytes();
|
let pk_bytes = keyring.public_key_bundle().as_bytes();
|
||||||
|
|
@ -243,18 +350,23 @@ impl WasmClient {
|
||||||
|
|
||||||
let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
||||||
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
|
||||||
let signature = signer.sign(&sig_payload)
|
let signature = signer
|
||||||
|
.sign(&sig_payload)
|
||||||
.map_err(|e| js_error(&format!("signature failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("signature failed: {}", e)))?;
|
||||||
|
|
||||||
let frame = CommunicationValue::new(CommunicationType::Register)
|
let frame = CommunicationValue::new(CommunicationType::Register)
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
||||||
.add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(client_nonce))
|
.add_typed_default(
|
||||||
|
DataType::ClientNonce,
|
||||||
|
DataValue::UnsignedNumber(client_nonce),
|
||||||
|
)
|
||||||
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes))
|
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes))
|
||||||
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
|
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
|
||||||
|
|
||||||
let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
|
let transport =
|
||||||
|
WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
|
||||||
let inner = transport.inner().clone();
|
let inner = transport.inner().clone();
|
||||||
transport.send_frame(&frame).await?;
|
transport.send_frame(&frame).await?;
|
||||||
|
|
||||||
|
|
@ -262,26 +374,47 @@ impl WasmClient {
|
||||||
let resp_comm = CommunicationValue::from_bytes(&response)
|
let resp_comm = CommunicationValue::from_bytes(&response)
|
||||||
.map_err(|e| js_error(&format!("parse response: {}", e)))?;
|
.map_err(|e| js_error(&format!("parse response: {}", e)))?;
|
||||||
|
|
||||||
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
let resp_type = resp_comm.get_type();
|
let resp_type = resp_comm.get_type();
|
||||||
let expected_type = CommunicationTypeId(18); // RegisterResponse
|
let expected_type = CommunicationType::RegisterResponse.to_id(&tm);
|
||||||
if resp_type != expected_type {
|
if resp_type != expected_type {
|
||||||
return Err(js_error("unexpected response type"));
|
self.set_state(ConnectionState::Disconnected);
|
||||||
|
return Err(unexpected_response_type_error(
|
||||||
|
"auth_register",
|
||||||
|
expected_type,
|
||||||
|
resp_type,
|
||||||
|
&response,
|
||||||
|
&resp_comm,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp_comm.get_data(DataTypeId(11)) != &DataValue::BoolTrue {
|
if resp_comm.get_data(DataType::Connected.to_id(&tm)) != &DataValue::BoolTrue {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
return Err(js_error("host rejected registration"));
|
return Err(js_error("host rejected registration"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let echo = resp_comm.get_data(DataTypeId(7));
|
let echo = resp_comm.get_data(DataType::ClientNonce.to_id(&tm));
|
||||||
if *echo != DataValue::UnsignedNumber(client_nonce) {
|
if *echo != DataValue::UnsignedNumber(client_nonce) {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
return Err(js_error("nonce mismatch"));
|
return Err(js_error("nonce mismatch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let assigned_id = match resp_comm.get_data(DataTypeId(6)) {
|
let assigned_id = match resp_comm.get_data(DataType::Id.to_id(&tm)) {
|
||||||
DataValue::UnsignedNumber(n) => *n as u64,
|
DataValue::UnsignedNumber(n) => *n as u64,
|
||||||
_ => return Err(js_error("missing assigned ID")),
|
_ => {
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
|
return Err(js_error("missing assigned ID"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Verify the host's signature over the handshake (register: id is the
|
||||||
|
// host-assigned id).
|
||||||
|
if let Err(e) = verify_host_signature(&resp_comm, &tm, &host_pk, assigned_id, client_nonce)
|
||||||
|
{
|
||||||
|
self.set_state(ConnectionState::Disconnected);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
self.transport = Some(transport);
|
self.transport = Some(transport);
|
||||||
self.set_state(ConnectionState::Connected);
|
self.set_state(ConnectionState::Connected);
|
||||||
|
|
||||||
|
|
@ -289,7 +422,9 @@ impl WasmClient {
|
||||||
let on_msg = self.on_message.clone();
|
let on_msg = self.on_message.clone();
|
||||||
let on_err = self.on_error.clone();
|
let on_err = self.on_error.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
WasmTransport::from_inner(inner).receive_loop(on_msg, on_err).await;
|
WasmTransport::from_inner(inner)
|
||||||
|
.receive_loop(on_msg, on_err)
|
||||||
|
.await;
|
||||||
state.set(ConnectionState::Disconnected);
|
state.set(ConnectionState::Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -306,16 +441,17 @@ impl WasmClient {
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn disconnect(&mut self) {
|
pub fn disconnect(&mut self) {
|
||||||
if let Some(t) = &self.transport { t.close(); }
|
if let Some(t) = &self.transport {
|
||||||
|
t.close();
|
||||||
|
}
|
||||||
self.transport = None;
|
self.transport = None;
|
||||||
self.set_state(ConnectionState::Disconnected);
|
self.set_state(ConnectionState::Disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_state(&self, new_state: ConnectionState) {
|
fn set_state(&self, new_state: ConnectionState) {
|
||||||
self.state.set(new_state);
|
self.state.set(new_state);
|
||||||
let _ = self.on_state_change.call1(
|
let _ = self
|
||||||
&JsValue::NULL,
|
.on_state_change
|
||||||
&JsValue::from(new_state as u8),
|
.call1(&JsValue::NULL, &JsValue::from(new_state as u8));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use mtp_crypto::{
|
use mtp_crypto::{
|
||||||
AeadDecrypt, AeadEncrypt, Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring,
|
AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, KemPrivateKey, KemPublicKey,
|
||||||
PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
|
Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
|
||||||
SignaturePublicKey, SignatureScheme, ChaCha20Poly1305, sha256, sha256_double,
|
SignaturePublicKey, SignatureScheme, sha256, sha256_double,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::js_error;
|
use crate::error::js_error;
|
||||||
|
|
@ -28,8 +28,8 @@ impl WasmKeyring {
|
||||||
/// Deserialise a keyring from bytes.
|
/// Deserialise a keyring from bytes.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<WasmKeyring, JsValue> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<WasmKeyring, JsValue> {
|
||||||
let inner =
|
let inner = Keyring::from_bytes(bytes)
|
||||||
Keyring::from_bytes(bytes).map_err(|e| js_error(&format!("Keyring::from_bytes: {}", e)))?;
|
.map_err(|e| js_error(&format!("Keyring::from_bytes: {}", e)))?;
|
||||||
Ok(Self { inner })
|
Ok(Self { inner })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +217,11 @@ pub fn ed25519_generate() -> Result<JsValue, JsValue> {
|
||||||
|
|
||||||
/// Standalone Ed25519 signature verification.
|
/// Standalone Ed25519 signature verification.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn ed25519_verify(public_key: Vec<u8>, message: &[u8], signature: &[u8]) -> Result<(), JsValue> {
|
pub fn ed25519_verify(
|
||||||
|
public_key: Vec<u8>,
|
||||||
|
message: &[u8],
|
||||||
|
signature: &[u8],
|
||||||
|
) -> Result<(), JsValue> {
|
||||||
let pk = SignaturePublicKey::new(public_key);
|
let pk = SignaturePublicKey::new(public_key);
|
||||||
mtp_crypto::verify_ed25519(&pk, message, signature)
|
mtp_crypto::verify_ed25519(&pk, message, signature)
|
||||||
.map_err(|e| js_error(&format!("verify_ed25519 failed: {}", e)))
|
.map_err(|e| js_error(&format!("verify_ed25519 failed: {}", e)))
|
||||||
|
|
@ -245,7 +249,12 @@ pub fn wasm_sha256_double(data: &[u8]) -> Vec<u8> {
|
||||||
|
|
||||||
/// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`.
|
/// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn wasm_hkdf_expand(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Result<Vec<u8>, JsValue> {
|
pub fn wasm_hkdf_expand(
|
||||||
|
ikm: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
info: &[u8],
|
||||||
|
len: usize,
|
||||||
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
mtp_crypto::hkdf_expand(ikm, salt, info, len)
|
mtp_crypto::hkdf_expand(ikm, salt, info, len)
|
||||||
.map_err(|e| js_error(&format!("hkdf_expand failed: {}", e)))
|
.map_err(|e| js_error(&format!("hkdf_expand failed: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
@ -416,7 +425,8 @@ mod tests {
|
||||||
fn sha256_empty() {
|
fn sha256_empty() {
|
||||||
let result = wasm_sha256(b"");
|
let result = wasm_sha256(b"");
|
||||||
// SHA-256 of empty string
|
// SHA-256 of empty string
|
||||||
let expected = hex::decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
let expected =
|
||||||
|
hex::decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
.expect("hex decode");
|
.expect("hex decode");
|
||||||
assert_eq!(result, expected);
|
assert_eq!(result, expected);
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +434,8 @@ mod tests {
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn sha256_hello() {
|
fn sha256_hello() {
|
||||||
let result = wasm_sha256(b"hello");
|
let result = wasm_sha256(b"hello");
|
||||||
let expected = hex::decode("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
|
let expected =
|
||||||
|
hex::decode("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
|
||||||
.expect("hex decode");
|
.expect("hex decode");
|
||||||
assert_eq!(result, expected);
|
assert_eq!(result, expected);
|
||||||
}
|
}
|
||||||
|
|
@ -443,8 +454,7 @@ mod tests {
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn hkdf_expand_produces_correct_length() {
|
fn hkdf_expand_produces_correct_length() {
|
||||||
let result = wasm_hkdf_expand(b"ikm", b"salt", b"info", 32)
|
let result = wasm_hkdf_expand(b"ikm", b"salt", b"info", 32).expect("hkdf_expand failed");
|
||||||
.expect("hkdf_expand failed");
|
|
||||||
assert_eq!(result.len(), 32);
|
assert_eq!(result.len(), 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -457,22 +467,21 @@ mod tests {
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn derive_encryption_key_roundtrip() {
|
fn derive_encryption_key_roundtrip() {
|
||||||
let key = wasm_derive_encryption_key(b"password", b"salt", b"context")
|
let key =
|
||||||
.expect("derive failed");
|
wasm_derive_encryption_key(b"password", b"salt", b"context").expect("derive failed");
|
||||||
assert_eq!(key.len(), 32);
|
assert_eq!(key.len(), 32);
|
||||||
|
|
||||||
// Deterministic: same inputs = same key
|
// Deterministic: same inputs = same key
|
||||||
let key2 = wasm_derive_encryption_key(b"password", b"salt", b"context")
|
let key2 =
|
||||||
.expect("derive failed");
|
wasm_derive_encryption_key(b"password", b"salt", b"context").expect("derive failed");
|
||||||
assert_eq!(key, key2);
|
assert_eq!(key, key2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn derive_encryption_key_different_inputs_different_key() {
|
fn derive_encryption_key_different_inputs_different_key() {
|
||||||
let key = wasm_derive_encryption_key(b"pass1", b"salt", b"context")
|
let key = wasm_derive_encryption_key(b"pass1", b"salt", b"context").expect("derive failed");
|
||||||
.expect("derive failed");
|
let key2 =
|
||||||
let key2 = wasm_derive_encryption_key(b"pass2", b"salt", b"context")
|
wasm_derive_encryption_key(b"pass2", b"salt", b"context").expect("derive failed");
|
||||||
.expect("derive failed");
|
|
||||||
assert_ne!(key, key2);
|
assert_ne!(key, key2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use mtp_codec::{
|
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue};
|
||||||
CommunicationType, CommunicationTypeId, CommunicationValue, DataType, DataTypeId, DataValue,
|
use mtp_crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm};
|
||||||
};
|
|
||||||
use mtp_type_map::communication_type_name;
|
use mtp_type_map::communication_type_name;
|
||||||
use mtp_crypto::{Ed25519Signer, EncryptionType, Keyring, SigAlgorithm};
|
|
||||||
|
|
||||||
use crate::error::js_error;
|
use crate::error::js_error;
|
||||||
|
|
||||||
|
|
@ -17,8 +15,14 @@ pub fn build_ping_frame(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
) -> Result<Vec<u8>, JsValue> {
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
let mut msg = CommunicationValue::new(CommunicationType::Ping)
|
let mut msg = CommunicationValue::new(CommunicationType::Ping)
|
||||||
.add_typed_default(DataType::Description, DataValue::Str(description.to_string()))
|
.add_typed_default(
|
||||||
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(timestamp as u128))
|
DataType::Description,
|
||||||
|
DataValue::Str(description.to_string()),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::Timestamp,
|
||||||
|
DataValue::UnsignedNumber(timestamp as u128),
|
||||||
|
)
|
||||||
.with_sender(client_id);
|
.with_sender(client_id);
|
||||||
|
|
||||||
if !data.is_empty() {
|
if !data.is_empty() {
|
||||||
|
|
@ -32,13 +36,18 @@ pub fn build_ping_frame(
|
||||||
/// Build a demo Ping frame with encrypted and signed containers
|
/// Build a demo Ping frame with encrypted and signed containers
|
||||||
/// (mirrors the Rust client example but uses only reserved data types).
|
/// (mirrors the Rust client example but uses only reserved data types).
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn build_demo_message(client_id: u64, keyring_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
|
pub fn build_demo_message(
|
||||||
|
client_id: u64,
|
||||||
|
keyring_bytes: &[u8],
|
||||||
|
host_bundle_bytes: &[u8],
|
||||||
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
let keyring = Keyring::from_bytes(keyring_bytes)
|
let keyring = Keyring::from_bytes(keyring_bytes)
|
||||||
.map_err(|e| js_error(&format!("invalid keyring: {}", e)))?;
|
.map_err(|e| js_error(&format!("invalid keyring: {}", e)))?;
|
||||||
|
|
||||||
// Demo encrypts to its own KEM public key (encrypt-to-self) so the roundtrip
|
// Encrypt to the server's KEM public key; the server decrypts with its keyring.
|
||||||
// is self-contained; a real client would encrypt to the server's bundle.
|
// (The client keyring only needs the Ed25519 signing key for this demo.)
|
||||||
let recipient = keyring.public_key_bundle();
|
let recipient = PublicKeyBundle::from_bytes(host_bundle_bytes)
|
||||||
|
.map_err(|e| js_error(&format!("invalid host bundle: {}", e)))?;
|
||||||
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
||||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
||||||
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
|
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
|
||||||
|
|
@ -49,7 +58,8 @@ pub fn build_demo_message(client_id: u64, keyring_bytes: &[u8]) -> Result<Vec<u8
|
||||||
(DataTypeId(2), DataValue::UnsignedNumber(42)),
|
(DataTypeId(2), DataValue::UnsignedNumber(42)),
|
||||||
]);
|
]);
|
||||||
let mut dv_enc = inner_enc;
|
let mut dv_enc = inner_enc;
|
||||||
dv_enc.encrypt_container(enc_type, &recipient, b"demo-aad")
|
dv_enc
|
||||||
|
.encrypt_container(enc_type, &recipient, b"demo-aad")
|
||||||
.ok_or_else(|| js_error("encryption failed"))?;
|
.ok_or_else(|| js_error("encryption failed"))?;
|
||||||
|
|
||||||
// Signed container
|
// Signed container
|
||||||
|
|
@ -58,23 +68,40 @@ pub fn build_demo_message(client_id: u64, keyring_bytes: &[u8]) -> Result<Vec<u8
|
||||||
(DataTypeId(2), DataValue::UnsignedNumber(99)),
|
(DataTypeId(2), DataValue::UnsignedNumber(99)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sig = inner_sig;
|
let mut dv_sig = inner_sig;
|
||||||
dv_sig.sign_container(SigAlgorithm::ED25519, &signer)
|
dv_sig
|
||||||
|
.sign_container(SigAlgorithm::ED25519, &signer)
|
||||||
.ok_or_else(|| js_error("signing failed"))?;
|
.ok_or_else(|| js_error("signing failed"))?;
|
||||||
|
|
||||||
// Signed + encrypted container
|
// Signed + encrypted container
|
||||||
let inner_sec = DataValue::Container(vec![
|
let inner_sec = DataValue::Container(vec![
|
||||||
(DataTypeId(1), DataValue::Str("signed+encrypted payload".into())),
|
(
|
||||||
|
DataTypeId(1),
|
||||||
|
DataValue::Str("signed+encrypted payload".into()),
|
||||||
|
),
|
||||||
(DataTypeId(2), DataValue::UnsignedNumber(7)),
|
(DataTypeId(2), DataValue::UnsignedNumber(7)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sec = inner_sec;
|
let mut dv_sec = inner_sec;
|
||||||
dv_sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"demo-aad")
|
dv_sec
|
||||||
|
.sign_and_encrypt_container(
|
||||||
|
SigAlgorithm::ED25519,
|
||||||
|
&signer,
|
||||||
|
enc_type,
|
||||||
|
&recipient,
|
||||||
|
b"demo-aad",
|
||||||
|
)
|
||||||
.ok_or_else(|| js_error("sign+encrypt failed"))?;
|
.ok_or_else(|| js_error("sign+encrypt failed"))?;
|
||||||
|
|
||||||
let timestamp = js_sys::Date::now() as u64;
|
let timestamp = js_sys::Date::now() as u64;
|
||||||
|
|
||||||
let msg = CommunicationValue::new(CommunicationType::Ping)
|
let msg = CommunicationValue::new(CommunicationType::Ping)
|
||||||
.add_typed_default(DataType::Description, DataValue::Str("MTP WASM Demo".into()))
|
.add_typed_default(
|
||||||
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(timestamp as u128))
|
DataType::Description,
|
||||||
|
DataValue::Str("MTP WASM Demo".into()),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::Timestamp,
|
||||||
|
DataValue::UnsignedNumber(timestamp as u128),
|
||||||
|
)
|
||||||
.add_typed_default(DataType::Version, DataValue::Str("demo-wasm".into()))
|
.add_typed_default(DataType::Version, DataValue::Str("demo-wasm".into()))
|
||||||
.with_sender(client_id);
|
.with_sender(client_id);
|
||||||
|
|
||||||
|
|
@ -170,7 +197,11 @@ pub fn parse_response_frame(frame: &[u8]) -> Result<String, JsValue> {
|
||||||
|
|
||||||
let obj = js_sys::Object::new();
|
let obj = js_sys::Object::new();
|
||||||
|
|
||||||
let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("_id"), &JsValue::from(comm.get_id()));
|
let _ = js_sys::Reflect::set(
|
||||||
|
&obj,
|
||||||
|
&JsValue::from_str("_id"),
|
||||||
|
&JsValue::from(comm.get_id()),
|
||||||
|
);
|
||||||
|
|
||||||
let type_name = communication_type_name(comm.get_type().0).unwrap_or("Unknown");
|
let type_name = communication_type_name(comm.get_type().0).unwrap_or("Unknown");
|
||||||
let _ = js_sys::Reflect::set(
|
let _ = js_sys::Reflect::set(
|
||||||
|
|
@ -196,12 +227,21 @@ pub fn parse_response_frame(frame: &[u8]) -> Result<String, JsValue> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let stringified = js_sys::JSON::stringify(&obj)
|
let stringified =
|
||||||
.map_err(|_| js_error("JSON stringify failed"))?;
|
js_sys::JSON::stringify(&obj).map_err(|_| js_error("JSON stringify failed"))?;
|
||||||
stringified.as_string()
|
stringified
|
||||||
|
.as_string()
|
||||||
.ok_or_else(|| js_error("JSON stringify result not a string"))
|
.ok_or_else(|| js_error("JSON stringify result not a string"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse any MTP frame into the human-readable CommunicationValue display form.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn format_frame(frame: &[u8]) -> Result<String, JsValue> {
|
||||||
|
let comm = CommunicationValue::from_bytes(frame)
|
||||||
|
.map_err(|e| js_error(&format!("parse failed: {}", e)))?;
|
||||||
|
Ok(comm.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
@ -215,8 +255,14 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(cv.get_type(), CommunicationTypeId(19)); // Ping
|
assert_eq!(cv.get_type(), CommunicationTypeId(19)); // Ping
|
||||||
assert_eq!(cv.get_sender(), 42);
|
assert_eq!(cv.get_sender(), 42);
|
||||||
assert_eq!(cv.get_data(DataTypeId(4)), &DataValue::Str("test-ping".into()));
|
assert_eq!(
|
||||||
assert_eq!(cv.get_data(DataTypeId(5)), &DataValue::UnsignedNumber(1234567890));
|
cv.get_data(DataTypeId(4)),
|
||||||
|
&DataValue::Str("test-ping".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cv.get_data(DataTypeId(5)),
|
||||||
|
&DataValue::UnsignedNumber(1234567890)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
|
|
@ -227,9 +273,15 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(cv.get_type(), CommunicationTypeId(19));
|
assert_eq!(cv.get_type(), CommunicationTypeId(19));
|
||||||
assert_eq!(cv.get_sender(), 99);
|
assert_eq!(cv.get_sender(), 99);
|
||||||
assert_eq!(cv.get_data(DataTypeId(4)), &DataValue::Str("with-data".into()));
|
assert_eq!(
|
||||||
|
cv.get_data(DataTypeId(4)),
|
||||||
|
&DataValue::Str("with-data".into())
|
||||||
|
);
|
||||||
assert_eq!(cv.get_data(DataTypeId(5)), &DataValue::UnsignedNumber(555));
|
assert_eq!(cv.get_data(DataTypeId(5)), &DataValue::UnsignedNumber(555));
|
||||||
assert_eq!(cv.get_data(DataTypeId(6)), &DataValue::Bytes(payload.to_vec()));
|
assert_eq!(
|
||||||
|
cv.get_data(DataTypeId(6)),
|
||||||
|
&DataValue::Bytes(payload.to_vec())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
|
|
@ -241,12 +293,13 @@ mod tests {
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn build_demo_message_roundtrip() {
|
fn build_demo_message_roundtrip() {
|
||||||
// A full keyring is required: the demo now KEM-encrypts to its own
|
// The demo KEM-encrypts to the host's bundle, so a real host keypair is
|
||||||
// public key, so the KEM keypair must be real.
|
// required; the client keyring only needs its Ed25519 signing key.
|
||||||
let keyring = Keyring::generate();
|
let keyring = Keyring::generate();
|
||||||
let keyring_bytes = keyring.to_bytes();
|
let keyring_bytes = keyring.to_bytes();
|
||||||
|
let host_bundle = Keyring::generate().public_key_bundle().as_bytes();
|
||||||
|
|
||||||
let result = build_demo_message(7, &keyring_bytes);
|
let result = build_demo_message(7, &keyring_bytes, &host_bundle);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let bytes = result.unwrap();
|
let bytes = result.unwrap();
|
||||||
|
|
@ -254,12 +307,16 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(cv.get_type(), CommunicationTypeId(19)); // Ping
|
assert_eq!(cv.get_type(), CommunicationTypeId(19)); // Ping
|
||||||
assert_eq!(cv.get_sender(), 7);
|
assert_eq!(cv.get_sender(), 7);
|
||||||
assert_eq!(cv.get_data(DataTypeId(4)), &DataValue::Str("MTP WASM Demo".into()));
|
assert_eq!(
|
||||||
|
cv.get_data(DataTypeId(4)),
|
||||||
|
&DataValue::Str("MTP WASM Demo".into())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn build_demo_message_invalid_keyring() {
|
fn build_demo_message_invalid_keyring() {
|
||||||
let result = build_demo_message(1, b"not-a-valid-keyring");
|
let host_bundle = Keyring::generate().public_key_bundle().as_bytes();
|
||||||
|
let result = build_demo_message(1, b"not-a-valid-keyring", &host_bundle);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
assert!(err.as_string().unwrap().contains("invalid keyring"));
|
assert!(err.as_string().unwrap().contains("invalid keyring"));
|
||||||
|
|
@ -278,11 +335,13 @@ mod tests {
|
||||||
let result = parse_auth_response(&resp).expect("parse failed");
|
let result = parse_auth_response(&resp).expect("parse failed");
|
||||||
|
|
||||||
let connected = js_sys::Reflect::get(&result, &"connected".into())
|
let connected = js_sys::Reflect::get(&result, &"connected".into())
|
||||||
.ok().and_then(|v| v.as_bool());
|
.ok()
|
||||||
|
.and_then(|v| v.as_bool());
|
||||||
assert_eq!(connected, Some(true));
|
assert_eq!(connected, Some(true));
|
||||||
|
|
||||||
let id = js_sys::Reflect::get(&result, &"assignedId".into())
|
let id = js_sys::Reflect::get(&result, &"assignedId".into())
|
||||||
.ok().and_then(|v| v.as_f64());
|
.ok()
|
||||||
|
.and_then(|v| v.as_f64());
|
||||||
assert_eq!(id, Some(42.0));
|
assert_eq!(id, Some(42.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,7 +355,8 @@ mod tests {
|
||||||
let result = parse_auth_response(&resp).expect("parse failed");
|
let result = parse_auth_response(&resp).expect("parse failed");
|
||||||
|
|
||||||
let connected = js_sys::Reflect::get(&result, &"connected".into())
|
let connected = js_sys::Reflect::get(&result, &"connected".into())
|
||||||
.ok().and_then(|v| v.as_bool());
|
.ok()
|
||||||
|
.and_then(|v| v.as_bool());
|
||||||
assert_eq!(connected, Some(false));
|
assert_eq!(connected, Some(false));
|
||||||
|
|
||||||
// rejected should have no assignedId
|
// rejected should have no assignedId
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ use web_sys::{WebTransport, WebTransportHash, WebTransportOptions};
|
||||||
|
|
||||||
use crate::error::js_error;
|
use crate::error::js_error;
|
||||||
|
|
||||||
|
const CLOSE_FRAME_LEN: u32 = u32::MAX;
|
||||||
|
|
||||||
/// Given a `SendStream` (old API with `.writable` or new API where stream IS a WritableStream),
|
/// Given a `SendStream` (old API with `.writable` or new API where stream IS a WritableStream),
|
||||||
/// return the object to call `.getWriter()` on.
|
/// return the object to call `.getWriter()` on.
|
||||||
fn resolve_stream_writable(send_stream: &JsValue) -> Result<JsValue, JsValue> {
|
fn resolve_stream_writable(send_stream: &JsValue) -> Result<JsValue, JsValue> {
|
||||||
|
|
@ -199,10 +201,17 @@ impl WasmTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
if buffer.len() >= 4 {
|
if buffer.len() >= 4 {
|
||||||
let frame_len =
|
let frame_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
|
||||||
u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
|
if frame_len == CLOSE_FRAME_LEN {
|
||||||
if 4 + frame_len <= buffer.len() {
|
return Err(js_error("connection closed before frame"));
|
||||||
return Ok(buffer[4..4 + frame_len].to_vec());
|
}
|
||||||
|
|
||||||
|
let frame_len = frame_len as usize;
|
||||||
|
let Some(frame_end) = 4usize.checked_add(frame_len) else {
|
||||||
|
return Err(js_error("invalid frame length"));
|
||||||
|
};
|
||||||
|
if frame_end <= buffer.len() {
|
||||||
|
return Ok(buffer[4..frame_end].to_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,13 +239,7 @@ impl WasmTransport {
|
||||||
let result = match read_fn.call0(&reader_val) {
|
let result = match read_fn.call0(&reader_val) {
|
||||||
Ok(p) => match JsFuture::from(p.unchecked_into::<js_sys::Promise>()).await {
|
Ok(p) => match JsFuture::from(p.unchecked_into::<js_sys::Promise>()).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(_) => break,
|
||||||
let _ = on_error.call1(
|
|
||||||
&JsValue::NULL,
|
|
||||||
&JsValue::from_str(&format!("read stream failed: {:?}", e)),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
};
|
};
|
||||||
|
|
@ -302,29 +305,43 @@ impl WasmTransport {
|
||||||
|
|
||||||
// Extract all complete frames from the buffer
|
// Extract all complete frames from the buffer
|
||||||
while buffer.len() >= 4 {
|
while buffer.len() >= 4 {
|
||||||
let frame_len =
|
let frame_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
|
||||||
u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
|
if frame_len == CLOSE_FRAME_LEN {
|
||||||
if 4 + frame_len > buffer.len() {
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame_len = frame_len as usize;
|
||||||
|
let Some(frame_end) = 4usize.checked_add(frame_len) else {
|
||||||
|
return Err(js_error("invalid frame length"));
|
||||||
|
};
|
||||||
|
if frame_end > buffer.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let frame = buffer[4..4 + frame_len].to_vec();
|
let frame = buffer[4..frame_end].to_vec();
|
||||||
let arr = js_sys::Uint8Array::from(&frame[..]);
|
let arr = js_sys::Uint8Array::from(&frame[..]);
|
||||||
let _ = on_message.call1(&JsValue::NULL, &arr);
|
let _ = on_message.call1(&JsValue::NULL, &arr);
|
||||||
buffer.drain(..4 + frame_len);
|
buffer.drain(..frame_end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process any remaining complete frames after stream closes
|
// Process any remaining complete frames after stream closes
|
||||||
while buffer.len() >= 4 {
|
while buffer.len() >= 4 {
|
||||||
let frame_len =
|
let frame_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
|
||||||
u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
|
if frame_len == CLOSE_FRAME_LEN {
|
||||||
if 4 + frame_len > buffer.len() {
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame_len = frame_len as usize;
|
||||||
|
let Some(frame_end) = 4usize.checked_add(frame_len) else {
|
||||||
|
return Err(js_error("invalid frame length"));
|
||||||
|
};
|
||||||
|
if frame_end > buffer.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let frame = buffer[4..4 + frame_len].to_vec();
|
let frame = buffer[4..frame_end].to_vec();
|
||||||
let arr = js_sys::Uint8Array::from(&frame[..]);
|
let arr = js_sys::Uint8Array::from(&frame[..]);
|
||||||
let _ = on_message.call1(&JsValue::NULL, &arr);
|
let _ = on_message.call1(&JsValue::NULL, &arr);
|
||||||
buffer.drain(..4 + frame_len);
|
buffer.drain(..frame_end);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue