[Fix] Harden MTP codec, transport, and SDK security

This commit is contained in:
Alex Emmet 2026-08-18 20:57:45 +02:00
commit a7e804c603
No known key found for this signature in database
73 changed files with 11892 additions and 5756 deletions

View file

@ -142,7 +142,7 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
// Save for next session
let id = conn.client_id;
let keyring_bytes = keyring.to_bytes();
let keyring_bytes = keyring.try_to_bytes()?;
```
When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
@ -175,7 +175,7 @@ pub struct Keyring {
}
```
- Serialise: `keyring.to_bytes()` -> `Vec<u8>`
- Serialise: `keyring.try_to_bytes()` -> `Result<Zeroizing<Vec<u8>>, CryptoError>`
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`

View file

@ -212,7 +212,7 @@ let (kem_sk, kem_pk) = HybridKem::generate_keypair();
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
// Save to disk
let bytes = host_keyring.to_bytes();
let bytes = host_keyring.try_to_bytes()?;
std::fs::write("host_keys.bin", bytes)?;
```

View file

@ -55,8 +55,28 @@ Verified SDK results expose the authenticated `protectedVersion` and
`finalRecipientId` alongside the application content.
Native applications use the same schema through `ProtectedMessageBuilder` and
`open_protected`; language bindings delegate envelope construction and opening
to this codec boundary.
the replay-explicit `open_protected_checked` or `open_protected_without_replay`
APIs; language bindings delegate envelope construction and opening to this
codec boundary.
Message processing uses the replay-required native APIs
`open_protected_checked` and `open_relay_metadata_checked` (or the equivalent
browser client path). Stored-message or forensic tooling must opt into the
explicit `*_without_replay` APIs. Native in-memory guards are bounded and
configurable; durable guards must perform an atomic insert-if-absent on
`(signer ID, MessageId)`.
Protected identifiers have semantic limits separate from the generic codec
blob limit. The default maximum `MessageId` is 256 UTF-8 bytes and relay
metadata is limited to 1 MiB of encoded metadata. Deployments can provide
stricter limits through the receive policy. Limits are checked after
authentication and before retained values enter replay or application state.
Transport-derived resource policies use a conservative decoder allocation
factor of `4 * max_message_size`, in addition to the frame-size output limit.
This factor accounts for owned wrapper, recipient, ciphertext, and decoded
value copies; it is an implementation admission policy rather than a wire
field.
## Authentication Flow
@ -77,6 +97,15 @@ Client Host
Login proof binds the protocol version, client ID, host challenge, and client nonce. Registration proof binds the protocol version, public key bundle, host challenge, and client nonce. The host challenge is generated per connection.
Authentication attempts pass through a deployment-configurable limiter before
client lookup, key validation, challenge signing, or registration callbacks.
The default host configuration uses a bounded in-memory window. Hosts may key
limits by connection, peer identity, claimed client ID, or registration flow.
When identity concealment is enabled, an unknown client ID follows a dummy
challenge/proof path and receives the same generic authentication failure as a
known client with an invalid proof; disabling concealment restores the legacy
identity-specific response for deployments where IDs are public.
`ForceAuthentication` requires login or registration. `AllowAuthentication` accepts authenticated and unauthenticated clients. `Unauthenticated` rejects authentication attempts. The connection states are `Pending`, `Authenticated`, `Unauthenticated`, and `Failed`.
## Version Negotiation

View file

@ -160,6 +160,14 @@ process boundaries. A guard should atomically record a new ID before
dispatching application content. Transport frame IDs must not be used for
this purpose.
Native message-processing boundaries require a replay guard through the
checked opening APIs. Reopening stored or forensic frames without a guard is
available only through an explicitly named `without_replay` API. The reference
in-memory guard is bounded and FIFO-evicts old entries, so it is a duplicate
suppression cache rather than durable replay protection. A durable deployment
must use an atomic insert-if-absent operation keyed by `(signer ID, MessageId)`;
a separate read followed by insert is race-prone.
`VerifiedRelayMetadata` is an authenticated capability rather than a caller
constructed data transfer object. Rust fields are private and the browser
implementation keeps authenticated state behind a branded class. Content
@ -174,9 +182,11 @@ fallback.
Verification takes a receiver-side `SignaturePolicy`/`ProtectionPolicy`.
`AnySupported` is useful for compatibility at the low-level codec boundary,
but protocol receivers should select `Ed25519` or `Dual`. The browser SDK uses
an explicit `ed25519` default and permits an operation or client override. It
never derives receive policy from the recipient keyring. The sender's
signature suite remains a separate choice. Signature policy must be applied
an explicit `ed25519` default and permits an operation or client override. Its
`MTPSecurityProfile` resolves protected-message sender/receiver suites,
encrypted-pipe suites, and the authentication PQ requirement together;
`any-supported` remains an explicit compatibility value. It never derives
receive policy from the recipient keyring. Signature policy must be applied
independently to relay metadata, relay content, and pipe session establishment.
### Key history and rotation
@ -261,16 +271,43 @@ to proceed with an invalid local decryption key.
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions.
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
Key-material parsing is explicit in the SDK: use the hex, Base64, or byte
helpers for encoded key material. Arbitrary strings are no longer treated as
passphrases by the compatibility `secretKeyFromString` helper. Applications
migrating data written by the old implicit-HKDF behavior can use the explicitly
named, deprecated `legacySecretKeyFromStringV1` helper only for that migration;
new data must not use it. Passwords must use the explicit Argon2id passphrase
API with a stored per-record salt and versioned parameters. The SDK's
`deriveKeyFromPassphrase` uses a worker when browser workers are available;
the explicitly named `deriveKeyFromPassphraseSync` form is for workers and
command-line migrations. HKDF helpers are for high-entropy key material and
are not password-hardening functions.
## Resource Limits and Operational Controls
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
The recursive codec applies additional defaults while parsing untrusted values:
maximum nesting depth 64, 65,536 value nodes, 16 MiB per blob or envelope,
and 64 encrypted recipients. Decrypted values are parsed with the same limits.
64 encrypted recipients, and a 64 MiB cumulative decoder allocation budget.
Decrypted values are parsed with the same limits. Transport derives the blob,
allocation, and encoder output budgets from its admitted frame size rather than
serializing an unrestricted recursive value first. The default transport
allocation budget is four times the admitted frame size to cover conservative
owned-copy and crypto-buffer accounting; deployments may choose another
factor with `DecodeLimits::for_transport_message_size_with_allocation_factor`.
The host does not provide a general authentication-attempt rate limiter.
Deploy authentication endpoints behind a rate-limiting proxy or add admission control through the host callbacks, including `GuestIdGenerator` where guest connections are permitted.
The host applies an authentication-attempt limiter before storage lookups,
public-key validation, challenge signing, and registration callbacks. The
default limiter is a bounded in-memory sliding window; configure a durable or
distributed limiter when limits must coordinate across host instances. Unknown
client IDs are sent through a fixed dummy challenge/proof path by default, so
they receive a generic authentication failure instead of an enumeration hint.
Deployments that intentionally publish client IDs can disable this concealment.
Keepalive Pong observation is bounded and accepts only the currently pending
ping ID. Unsolicited Pongs are dropped before they can consume application
receiver capacity.
## Security Limitations

View file

@ -1,6 +1,8 @@
# Type Map
This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml).
A type musn't be the version of MTP, it stays independant.
MTP version defines the codec. The Type-Map version defines the available Types.
## Binary Frame Format
@ -85,6 +87,15 @@ The envelope length counts the bytes after the length field. A recipient entry i
Protection nesting directly represents both signer-visibility choices: `Encrypted(Signed(Container))` keeps signer metadata private, while `Signed(Encrypted(Container))` exposes it. A frame with no outer sender and an `Encrypted(Signed(Container))` payload uses sealed sender. Sealed sender adds no flag or distinct wire type.
### Container ordering and signatures
Container entries are ordered sequences in the current format. Insertion order
is therefore semantic: two containers with the same field/value pairs in a
different order have different serialized bytes and different signatures. The
decoder rejects duplicate field IDs. Applications that need map semantics must
canonicalize their own input before signing; a future canonical map encoding
requires a protocol-format version and cannot be inferred by a receiver.
## TypeMap & Compile-Time Type Safety
A `TypeMap` maps Communication-Types and Data-Types to their wire IDs. Each protocol version has its own `TypeMap` because the same type name may use different wire IDs in different versions.
@ -175,17 +186,33 @@ mtp = { path = "..", features = ["host"] }
```rust
use mtp::codec::registry::{Registry, VersionedCodec};
use mtp::codec::{CommunicationType, CommunicationValue, DataValue};
use mtp_type_map::Version;
let registry = Registry::builtin();
let codec = VersionedCodec::new(registry);
let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap();
let value = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
codec.type_map(),
).with_payload(DataValue::Null);
// Encode with a specific version
let bytes = codec.encode(&value, Version(3, 0)).unwrap();
// The value must retain the negotiated map used to construct it.
let bytes = codec.encode(&value).unwrap();
// Decode with a specific version
let decoded = codec.decode(&bytes, Version(3, 0)).unwrap();
let decoded = codec.decode(&bytes).unwrap();
// A clear value can be migrated explicitly when the application has chosen
// that behavior. Protected values are not silently remapped.
let migrated = codec.encode_migrating(&value).unwrap();
```
`VersionedCodec::encode` compares the retained map identity (its protocol
version) and returns `CodecError::MissingTypeMap` or
`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's
map, while `try_merge` rejects frames from different maps before copying any
fields. The deprecated `merge` method records the error for compatibility; new
code should migrate to `try_merge` and handle the result.
## Customizing Type Maps in Downstream Projects
External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.