Maskura docs
Maskura is an S3-compatible gateway that runs your WebAssembly plugins over every object in transit. Point any S3 SDK, CLI, or tool at Maskura; each object passes through your plugin pipeline — filter, redact, encrypt, convert, validate, route — and the result is forwarded to any S3-compatible storage backend.
The quickest way to see it working is the README’s
60-second Docker demo:
run the published image with AUTH_DISABLED=true, open the demo dashboard, and
push a file through the pipeline. This site is the deeper documentation:
- Plugins — write, load, and compose your own Wasm filters.
- Avro gate — the typed Avro OCF processing path.
- Binary adapters — schema-aware binary reductors for typed formats.
- MCP — the local stdio MCP server for agent clients.
- End-to-end suite — the no-secrets local e2e harness.
- Security — the security model, trust boundaries, and deployment responsibilities.
- Architecture Decision Records — the decisions behind the design.
Demo
The same PII file, three ways — raw, redacted, and deterministic-encrypted — pushed
through aws s3 pointed at Maskura. Use the player controls to pause, scrub, and
change playback speed.
Maskura plugins: create and consume your own
Plugins are how Maskura transforms text data. A plugin is a WebAssembly component that receives each object’s payload, optionally transforms it, and returns a decision. Plugins run in a pipeline — the output of one is the input of the next — so you compose transforms: filter, then encrypt, then convert.
The interface
Plugins implement one world, s4:filter
(wit/s4-filter/world.wit):
| Function | Called | Purpose |
|---|---|---|
begin(context) | once per object | Per-object setup; context carries format, content-type, policy-version, and optional public-key-pem, stable-key, stable-fields |
transform(payload) | once per record | Transform the bytes; return emit(bytes), drop, or reject(reason) |
finish() | once at the end | Flush buffered output; return trailing bytes |
Sandbox limits: wasmtime, 64 MiB memory, 10K table entries, 512 KiB stack, no host
imports, and a fuel budget (MASKURA_WASM_FUEL, default 1B; enough for crypto filters).
Write one (Rust)
Cargo.toml:
[package]
name = "my-filter"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "0.60"
src/lib.rs:
#![allow(unused)]
fn main() {
wit_bindgen::generate!({
world: "filter",
path: "path/to/wit/s4-filter/world.wit",
});
struct MyFilter;
impl Guest for MyFilter {
fn begin(_context: Context) -> Result<(), String> {
Ok(())
}
fn transform(payload: Vec<u8>) -> Result<Decision, String> {
// ... transform the bytes ...
Ok(Decision::Emit(payload))
}
fn finish() -> Result<Vec<u8>, String> {
Ok(Vec::new())
}
}
export!(MyFilter);
}
Build and wrap as a component:
cargo build --release --target wasm32-unknown-unknown
wasm-tools component new target/wasm32-unknown-unknown/release/my_filter.wasm \
-o my-filter.component.wasm
filters/noop/ is a minimal example; filters/pii-default/ shows detection + redaction
with addr-spec / card-validate style libraries and a pure-Wasm crypto fallback.
Load it
At runtime — no gateway rebuild, no restart:
maskura plugin upload my-filter.component.wasm # prints the plugin id
maskura plugin list # shows pipeline order
maskura plugin reorder my-filter pii-default # output of one feeds the next
maskura plugin disable <id> # remove from the pipeline
maskura plugin delete <id> # drop the plugin
Or auto-load a directory of plugins at gateway startup:
MASKURA_PLUGINS_DIR=./components ./target/debug/s4-gateway
The default local setup preloads pii-default via MASKURA_FILTER_COMPONENT.
Decision semantics
emit(bytes)— pass the transformed bytes to the next plugin.drop— discard this record entirely.reject(reason)— fail the request with the reason.
Notes
- Plugins are pure byte-in/byte-out. The gateway handles transport (S3 API), auth, and storage.
- Plugins do not declare an output schema, so they cannot be used directly for Avro,
Parquet, or another typed binary format. Binary codecs use schema-aware transforms and
optional
s4:binary-reductorcomponents instead; see Binary adapters. - Filters shipped in-tree:
noop(pass-through baseline),pii-default,email-detect,ssn-detect,card-detect,envelope-encrypt,stable-encrypt. - The original WIT design is recorded in ADR-0001: component model and WIT.
Hosted workspaces (maskura hosted)
Self-hosted/local gateways load plugins with the maskura plugin commands above and a
directory (MASKURA_PLUGINS_DIR) at startup. Hosted Maskura workspaces instead manage
plugins as first-class relational configuration owned by the workspace owner. The
maskura plugin commands and directory auto-enable do not apply there and are not
available on hosted Maskura. Hosted management is available only when the hosted deployment
enables filter pipelines (and separately enables custom uploads). It authenticates with a
Supabase access token (MASKURA_ACCESS_TOKEN or --token) and a workspace ID
(MASKURA_WORKSPACE_ID or --workspace); a Maskura data-plane API key is never accepted
for hosted mutations. The s4ctl binary name remains available as an alias for
maskura.
export MASKURA_ACCESS_TOKEN=<supabase-jwt>
export MASKURA_WORKSPACE_ID=<workspace-uuid>
maskura hosted catalog # catalog + versions + capability grants
maskura hosted upload ./my-filter.component.wasm \
--slug my-filter --display-name "My Filter" --version 1.0.0 \
--world s4-filter@0.2.0 --wit-version 0.2.0 --capability stable_fields
maskura hosted validation <version-id> # poll the secret-free validation run
maskura hosted grant --installation-id <id> --capability stable_fields --version-id <version-id>
maskura hosted pipelines create write "redact"
maskura hosted pipelines draft <pipeline-id> --step <install-id>:<version-id>:config.json
maskura hosted pipelines publish <pipeline-id>
maskura hosted assign-default write --pipeline-id <id>
maskura hosted assign-bucket write ingest --pipeline-id <id>
maskura hosted audit
- Worlds. Components implement
s4-filter@0.1.0(no config) ors4-filter@0.2.0(operation+ optionalconfig-json). Config is only valid for v0.2 components. - Ordering. Draft steps run in the order given (
installation_id:version_id[:config]); the fingerprint covers ordered versions, enabled flags, configs, and grants. - Capability grants. A component only receives sensitive context (for example
stable_fields) after the owner explicitly grants it per installation/version. - Pass-through. An empty chain is only publishable when
--passthroughis set; a missing bucket assignment inherits the workspace default, and an exact bucket assignment replaces the chain entirely. - Read spooling. Custom read filters are spooled to encrypted storage and never disclosed as a raw fallback on failure.
- Ownership. Only workspace owners mutate plugins, grants, pipelines, or assignments; members may inspect the effective configuration and audit trail.
Encryption
This page explains how Maskura’s envelope encryption works, from the key you hand the gateway to the bytes that leave your writer. It is the long-form companion to the post-quantum feature page and ADR 0011.
The model
Encryption is envelope encryption with a fresh data key per field:
- The gateway generates a random 256-bit data encryption key (DEK) and encrypts the field with AES-256-GCM.
- The DEK is then key-encapsulated to your public key, so only the holder of the matching private key can recover it.
- Both the ciphertext and the encapsulated DEK are written to storage.
Maskura never sees a plaintext field, never sees the DEK after it is wrapped, and never holds your private key. Decryption happens entirely on the client.
The primitives
| Role | Primitive | Why |
|---|---|---|
| Data cipher | AES-256-GCM | Authenticated encryption; 256-bit keys resist Grover (~2^128) |
| Classical KEM | X25519 (ECDH) | Protects against an undiscovered weakness in a young PQ scheme |
| Post-quantum KEM | ML-KEM-768 (FIPS 203) | Protects against a cryptographically relevant quantum computer |
| Combiner | HKDF-SHA256 | Folds the two shared secrets into one DEK |
The construction is a hybrid KEM: the DEK is secure unless both X25519 and ML-KEM-768 are broken. This is the consensus position of the IETF hybrid key-exchange draft, NSA CNSA 2.0, and BSI.
The data path contains no signatures, so ML-DSA and SLH-DSA are out of scope — KEM-only.
Encapsulation (encrypt)
For each detected field the gateway:
m = 32 random bytes from a CSPRNG
(mlkem_ss, mlkem_ct) = ML-KEM-768.Encaps(ek, m) # ek = your ML-KEM public key
x25519_sk = 32 random bytes
x25519_epk = X25519(x25519_sk, basepoint)
x25519_ss = X25519(x25519_sk, your_x25519_pk)
dek = HKDF-SHA256(ikm = x25519_ss ‖ mlkem_ss,
salt = "",
info = "maskura/hybrid/envelope-dek/v1",
L = 32)
enc_dek = x25519_epk ‖ mlkem_ct # 1120 bytes
field = AES-256-GCM(dek, iv, plaintext)
The wrapped key material (enc_dek) carries everything you need to decrypt:
your X25519 ephemeral public key and the ML-KEM-768 ciphertext. The DEK itself
is never stored — it is derived from the two shared secrets on both sides.
Decapsulation (decrypt, client-side)
(x25519_epk, mlkem_ct) = split(enc_dek)
x25519_ss = X25519(your_x25519_sk, x25519_epk)
mlkem_ss = ML-KEM-768.Decaps(your_mlkem_dk, mlkem_ct)
dek = HKDF-SHA256(same construction as above)
plaintext = AES-256-GCM.Decrypt(dek, iv, ct ‖ tag)
The envelope
Each encrypted field becomes a JSON object with five fields:
{"alg":"X25519+ML-KEM-768/AES-256-GCM","iv":"<b64>","enc_dek":"<b64>","ct":"<b64>","tag":"<b64>"}
algidentifies the construction. ExistingRSA-OAEP/AES-256-GCMenvelopes remain decryptable (dual-alg read); new writes are hybrid-only.ivis the 12-byte AES-GCM nonce.enc_dekis the 1120-byte hybrid encapsulation (base64).ct/tagare the AES-256-GCM ciphertext and authentication tag.
Key format
Keys are a single base64 blob of fixed-length concatenation, wrapped in PEM:
-----BEGIN MASKURA HYBRID PUBLIC KEY-----
base64( x25519_pk ‖ mlkem_ek ) # 32 + 1184 = 1216 bytes
-----END MASKURA HYBRID PUBLIC KEY-----
-----BEGIN MASKURA HYBRID PRIVATE KEY-----
base64( x25519_sk ‖ mlkem_seed ) # 32 + 64 = 96 bytes
-----END MASKURA HYBRID PRIVATE KEY-----
| Component | Size |
|---|---|
| X25519 public key / shared secret | 32 B |
| ML-KEM-768 encapsulation key (public) | 1184 B |
| ML-KEM-768 ciphertext | 1088 B |
| ML-KEM-768 shared secret | 32 B |
| ML-KEM-768 seed (private key serialization) | 64 B |
| Hybrid public key | 1216 B |
| Hybrid private key | 96 B |
enc_dek | 1120 B |
The gateway’s public-key-pem config field is unchanged: it is still a string.
The private key never touches the gateway.
Security properties
- IND-CCA2. Both KEMs are CCA-secure; the HKDF combiner produces an indistinguishable DEK unless both are broken.
- Per-field keys. A fresh DEK per field means a compromise of one DEK does not expose any other field.
- Authenticated encryption. AES-GCM binds the ciphertext to its tag; any tampering fails decryption.
- Fail closed. If key encapsulation or encryption fails, the field is redacted rather than emitted as plaintext.
- Single-alg write. New writes are hybrid-only; legacy RSA keys are rejected for new writes and remain only for decrypting previously written objects.
Cost
The hybrid wrap trades a slightly larger key encapsulation for materially cheaper CPU than the RSA-OAEP wrap it replaces:
- ~34M Wasm fuel per encrypted field (vs ~52M for RSA-2048 OAEP).
enc_dekgrows from 256 B (RSA-2048) to 1120 B per field.
Measured numbers and methodology are in Benchmarks.
Post-quantum encryption
Status: shipped. Current builds wrap the data key with a hybrid X25519 + ML-KEM-768 key encapsulation. See Encryption for the details and ADR 0011 for the decision.
What it is
Maskura’s encryption filters protect fields with envelope encryption: a fresh AES-256-GCM key per field, wrapped with a public key so only the key holder can decrypt. Before this change that wrap was RSA-OAEP, which a quantum computer can break.
The post-quantum feature replaces the RSA-OAEP wrap with a hybrid X25519 + ML-KEM-768 key encapsulation:
- ML-KEM-768 (NIST FIPS 203) protects against a cryptographically relevant quantum computer (harvest-now-decrypt-later).
- X25519 protects against the risk of an undiscovered weakness in a young post-quantum scheme.
The data cipher stays AES-256-GCM, which is already post-quantum-safe.
What changes
- New envelope
alg:X25519+ML-KEM-768/AES-256-GCM. - New client key format (hybrid public/private keys).
- Existing
RSA-OAEPenvelopes remain decryptable (dual-alg read).
What does not change
- The plugin model, the
transform/finishinterface, and the sandbox are untouched — this is a new filter implementation, not a new runtime. - API-key secrets (
KeyWrapping) and KMS/Vault wrapping are symmetric and already safe.
See also
Avro OCF support
Avro Object Container File processing is a typed binary format, intentionally separate from JSON/CSV text processing: an Avro writer must know the complete output schema before it emits the first object.
Enablement
Avro processing is off by default. Operators enable it with
MASKURA_ENABLE_AVRO=true. Disabled requests are rejected before the request body is
polled. Raw GET, HEAD, and Range passthrough for stored Avro objects do not
require the gate; only processing (PUT, processed GET, staged multipart
completion) does.
Supported codec subset
The codec accepts:
- OCF records with
null,boolean,int,long,float,double,bytes, andstringvalues. - Records, arrays, maps with string keys, and exactly nullable unions of the
form
["null", T]. - Logical
date,time-millis,time-micros,timestamp-millis,timestamp-micros,timestamp-nanos,uuid, anddecimalvalues. - Nested combinations of those types, subject to Maskura schema/value IR limits.
It rejects recursive/named references, arbitrary unions, enums, and fixed
values. Input OCF blocks may use null, deflate, snappy, or zstandard;
generated output uses Zstandard with normalized metadata. Source input is capped
before the Avro library reads it, and every emitted value is validated against
the bounded Maskura IR schema.
Processing model
Each processed object follows this order:
Avro OCF -> Schema/Value IR -> BinaryReductor -> BinaryTransform
-> BinaryReductor restore -> validated Schema/Value IR -> Avro OCF
Text s4:filter plugins are not inserted in this flow. They receive opaque
bytes and cannot declare an output schema.
Envelope encryption
x-maskura-encrypt-fields selects comma-separated string schema paths, for example
email or contacts[*].email. With an authenticated public key, selected
fields become X25519+ML-KEM-768/AES-256-GCM envelope records and the Avro schema is
evolved before encoding. Without a public key, selected fields are redacted to
[REDACTED] while the string schema is preserved. Overlapping or invalid paths
are rejected. Multipart completion uses identity processing; field selection
applies to single PUT and processed GET.
Multipart and processed reads
Staged multipart completion concatenates the encrypted parts into one OCF source
and runs it through the same processor rather than treating parts as independent
streams. A processed read (x-maskura-process: read) runs the source through the
typed pump and stages the complete output in the existing encrypted read spool
before disclosure. Processed read failures never disclose raw source bytes.
Verify codec work
cargo test -p s4-gateway avro::tests
cargo test -p s4-gateway binary_pump::tests
The suite covers OCF schema/value round trips, nullable/container records, logical values, decimal precision/scale, source-size boundaries, transport chunk invariance, unsupported schemas, and processing through a typed transform.
Hive-compatible layouts
Maskura treats a Hive partition path as an ordinary object key. For example:
warehouse/customers/day=2026-08-30/part-000.avro
The Avro schema remains in the OCF header. Maskura does not provide a Hive Metastore, table DDL, SQL query engine, or ORC support.
Binary adapters
Typed binary formats such as Avro and Parquet do not pass through the
byte-oriented s4:filter plugin pipeline. A binary encoder needs its complete
output schema before it writes the first record. Use a binary reductor when a
format-specific logical type must be converted to a Maskura-supported type before a
typed transform, then reconstructed for output.
The contract is s4:binary-reductor@0.1.0 in
wit/s4-binary-reductor/world.wit.
Lifecycle
For one object, the host calls the component in this order:
plan(source-schema-ir)returns a reduced schema, owned claims, and an opaque reduction plan.reduce(plan, source-value-ir)runs once per source value.- The host applies schema-aware binary transforms to reduced values.
plan-restore(source-schema-ir, transformed-reduced-schema-ir, plan)returns the final output schema and an opaque restoration plan.restore(restore-plan, transformed-value-ir)runs once per retained value.
The component only owns paths it claims. The gateway verifies that claims point to a custom logical value or declared record, and rejects a schema mutation outside a claim. Plans are bound to the SHA-256 digest of the exact component; do not reuse plans across component versions.
Canonical IR
Schema and value inputs are canonical JSON representations of the bounded Maskura
IR. The definitive Rust types and validators are in
crates/gateway/src/binary_ir.rs.
- A nullable Avro-like field is represented by
"nullable": true, not an arbitrary union. - Custom logical values use
{"type":"custom","type_id":"...","value":...}. - Map keys are UTF-8 strings and map entries are canonicalized by key.
- The gateway validates every returned schema and value before it reaches an
encoder. Invalid or unsupported data must return
reductor-error, never a best-effort result.
The test fixture in
filters/test-binary-reductor/src/lib.rs
is the smallest complete example. It reduces vendor.money from a custom value
to a string and restores it after the typed transform.
Write an adapter
Create a cdylib crate that uses the workspace-compatible wit-bindgen release:
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.60"
Generate bindings and implement the exported Guest trait:
#![allow(unused)]
fn main() {
wit_bindgen::generate!({
world: "binary-reductor",
path: "path/to/maskura/wit/s4-binary-reductor/world.wit",
});
struct MyReductor;
impl Guest for MyReductor {
// Implement plan, reduce, plan_restore, and restore.
}
export!(MyReductor);
}
Build a bare component. Binary reductors receive no WASI and no other host imports, so do not use the WASI adapter used by text plugins:
cargo build --release --target wasm32-unknown-unknown
wasm-tools component new target/wasm32-unknown-unknown/release/my_reductor.wasm \
-o my-reductor.component.wasm
Required behavior
- Keep every returned IR, plan, claim, identifier, and diagnostic within the host limits. The host rejects oversized output.
- Claim each custom logical subtree that the component changes. Claims may not overlap or prefix one another.
- Treat
planandplan-restoreoutput as immutable. Preserve all state needed later in opaque plan bytes. - Return stable error codes and bounded diagnostics. Never include plaintext, keys, or full source records in diagnostics.
- Do not depend on filesystem, network, clocks, environment variables, or host imports. The binary-reductor sandbox intentionally provides none.
Test locally
From a Maskura checkout:
bash scripts/build-filters.sh
cargo test -p s4-wasm-runtime binary_reductor
cargo test -p s4-gateway binary_reductor::tests
Add conformance vectors beside the fixture for each new logical type. Cover round trips, invalid claims, invalid plan bytes, malformed IR, fuel exhaustion, deadlines, cancellation, and component-digest changes before connecting an adapter to a codec.
Current integration boundary
The Wasm runtime and gateway adapter are available to codec code. Runtime component selection for binary formats is intentionally separate from dashboard text-plugin upload: a byte filter cannot safely become a binary adapter merely by changing its file extension. A codec integration must explicitly select and pin its binary-reductor component.
MCP server
maskura-mcp is a local stdio Model Context Protocol server. It exposes four text
object tools to Claude, Codex, Cursor, and other MCP clients:
maskura_put_objectmaskura_get_objectmaskura_list_objectsmaskura_delete_object
The stdio server does not implement a second storage or processing path. Every tool calls the Maskura Gateway’s S3-compatible HTTP surface, so gateway authentication, the configured plugin pipeline, backend selection, limits, and metering still apply.
Install
Build and install from the public source:
cargo install --git https://github.com/231self/maskura --bin maskura-mcp s4-mcp
Linux x86_64 and arm64 binaries are also attached to each
Maskura GitHub release as
maskura-mcp-linux-amd64 and maskura-mcp-linux-arm64. The s4-mcp binary
and s4_* tools remain permanent compatibility aliases.
There is currently no npm package or public hosted MCP endpoint. The public
gateway does provide the foundation used by a hosted transport: shared typed
contracts in maskura-mcp-protocol (re-exported as s4_gateway::mcp) and trusted in-process execution through
s4_gateway::server::invoke_mcp.
Run locally
Start the published gateway image and copy the loopback URL printed by the CLI:
maskura local init
# Gateway: http://127.0.0.1:8080 (the selected port may differ)
The local gateway runs with AUTH_DISABLED=true. maskura-mcp still requires
an explicit credential shape so a configuration cannot accidentally become
credential-free when pointed at production. Use local-only placeholder values:
{
"mcpServers": {
"maskura-local": {
"command": "maskura-mcp",
"env": {
"MASKURA_GATEWAY_URL": "http://127.0.0.1:8080",
"MASKURA_ACCESS_KEY": "local",
"MASKURA_SECRET_KEY": "local"
}
}
}
}
Use the exact port printed by maskura local init. These placeholder
credentials are accepted only because that loopback gateway explicitly disables
authentication; never use AUTH_DISABLED on a network-accessible deployment.
For Kilo, the equivalent local entry in kilo.json is:
{
"mcp": {
"maskura-local": {
"type": "local",
"command": ["maskura-mcp"],
"environment": {
"MASKURA_GATEWAY_URL": "http://127.0.0.1:8080",
"MASKURA_ACCESS_KEY": "local",
"MASKURA_SECRET_KEY": "local"
},
"enabled": true
}
}
}
Connect to a hosted gateway
Create an MCP token in the Maskura dashboard, or through the dashboard API with a signed-in session JWT:
curl --fail-with-body \
--request POST "$MASKURA_GATEWAY_URL/dashboard/api/mcp-tokens" \
--header "Authorization: Bearer $MASKURA_SESSION_JWT" \
--header "Content-Type: application/json" \
--data '{"label":"desktop-agent","expires_in":2592000}'
The response reveals the s4m_... token once. Store it in a secret manager,
not in source control. The token remains bound to the workspace selected when
it was created.
Claude Desktop and Cursor use the standard mcpServers shape:
{
"mcpServers": {
"maskura": {
"command": "maskura-mcp",
"env": {
"MASKURA_GATEWAY_URL": "https://api.s4.231self.com",
"MASKURA_MCP_TOKEN": "s4m_your_token"
}
}
}
}
For Claude Desktop on macOS, place this under the mcpServers key in
~/Library/Application Support/Claude/claude_desktop_config.json. Cursor uses
.cursor/mcp.json in a project or its equivalent global MCP settings.
Kilo uses its local-process MCP configuration shape in kilo.json:
{
"mcp": {
"maskura": {
"type": "local",
"command": ["maskura-mcp"],
"environment": {
"MASKURA_GATEWAY_URL": "https://api.s4.231self.com",
"MASKURA_MCP_TOKEN": "s4m_your_token"
},
"enabled": true
}
}
}
Restart the client after changing its MCP configuration. Desktop applications often do not inherit shell environment variables, so use the client’s secret storage or a restricted configuration file when literal values are required.
A Maskura API key pair can be used instead:
{
"MASKURA_GATEWAY_URL": "https://api.s4.231self.com",
"MASKURA_ACCESS_KEY": "s4_your_access_key",
"MASKURA_SECRET_KEY": "s4s_your_secret_key"
}
MASKURA_MCP_TOKEN takes precedence when both credential forms are present. Secret
values are validated at startup and are omitted from debug output.
Legacy S4_* names remain accepted. If both forms are set, their values must
match exactly, including empty values, or startup fails closed.
Try it locally
The stdlib-only example connects over MCP stdio and lists the available tools:
export MASKURA_GATEWAY_URL="http://127.0.0.1:8080" # use the printed port
export MASKURA_ACCESS_KEY="local"
export MASKURA_SECRET_KEY="local"
python3 examples/mcp-client.py
Run a complete put, filtered get, paged list, and delete lifecycle:
MCP_EXAMPLE_RUN_MUTATIONS=1 \
MCP_EXAMPLE_BUCKET=agent-data \
python3 examples/mcp-client.py
The upload contains alice@example.com; the read result should contain
[REDACTED_EMAIL], proving the MCP call used the normal gateway filter path.
Equivalent requests from an MCP-enabled agent are:
Store "Contact alice@example.com" at agent-data/examples/mcp.txt with Maskura.
Read agent-data/examples/mcp.txt and show me the stored value.
List up to 10 keys under examples/ in agent-data.
Delete agent-data/examples/mcp.txt.
Tool behavior
maskura_put_object accepts a UTF-8 body and a content_type (default
text/plain; charset=utf-8). Maskura uses that Content-Type to select the processing
format before writing to the configured backend.
maskura_get_object returns the stored representation by default. Set process to
true to send x-maskura-process: read and run the configured read pipeline before
the MCP client receives the object.
maskura_list_objects performs S3 ListObjectsV2 with an optional prefix and returns
decoded object keys. maskura_delete_object deletes one bucket/key pair.
MCP text responses are limited to 8 MiB. Binary request/response bodies, presigning, hosted Streamable HTTP transport, and agent payment protocols are not part of this stdio release.
Hosted adapter boundary
A hosted adapter authenticates its transport session outside the engine, then
calls invoke_mcp with:
- an atomically resolved
AuthenticatedMcpPrincipalcontaining the credential UUID, derived policy identity, user, and immutable workspace - a server operation UUID
- a typed
ToolRequest - request/response byte limits, timeout, and cancellation token
The invocation enters the same gateway handlers used by S3, including control plane authorization, plugin resolution and filtering, workspace storage, transactions, and usage recording. It does not use loopback HTTP and does not accept credential, metering, backend-selection, or presigned URL headers. Cancellation interrupts active Wasm work and waits for route settlement before returning. Provider SDK calls do not expose a cooperative cancellation guarantee, so an in-flight backend call may finish first; if it commits, Maskura returns the settled committed outcome instead of reporting or releasing it as cancelled.
API keys and MCP tokens are bound to the workspace selected when they are created. User identity is retained separately so dashboard owners can list and revoke credentials. Credentials persisted before workspace binding was added remain visible for rotation but fail authentication; Maskura never infers a default workspace for them.
Local end-to-end suite
The gateway ships a local, no-secrets end-to-end suite that boots a real
MinIO S3 backend plus the gateway binary and exercises public HTTP features
against it. It runs in CI (ci.yml, weekly.yml, nightly.yml) and locally,
and needs no Supabase, Postgres, cloud credentials, or signed-in users.
just e2e # run the full suite
bash scripts/e2e-local.sh # same, without `just`
How it is structured
scripts/e2e-local.sh— the orchestrator. Boots the shared environment once, runs every feature script inscripts/e2e/features/, aggregates PASS/FAIL, and exits 0 only when all features pass.scripts/e2e/lib.sh— the shared harness contract and helpers (pass/fail, HTTP-status and content assertions, JSON field extraction).scripts/e2e/features/NN-*.sh— one discrete, independently-runnable feature script per concern. Feature order is lexicographic; stateful features (plugin management) run last.
A feature may also be run alone against a freshly booted environment:
bash scripts/e2e-local.sh 30-keys-s3-lifecycle
# accept a bare feature number/name or a path:
bash scripts/e2e-local.sh 30-keys-s3-lifecycle.sh
Boot contract
The orchestrator starts (or reuses) one shared environment for every feature:
- MinIO on
:9000— if a healthy MinIO is already listening there (minioadmin/minioadmin), it is reused instead of failing on the port clash (common when a dev/ad MinIO is running). Otherwisedocker compose -f local/docker-compose.yml up -d miniostarts one, torn down on exit. - Bucket
s4-localis created if missing. - Gateway (AUTH_DISABLED) on
$MASKURA_E2E_GW_PORT(default9010), single-tenant streaming against that MinIO, an isolatedkeys.json, the builtpii-defaultcomponent, andMASKURA_STREAMING_READ_MODE=passthrough. - Gateway, MinIO, and the filter/binaries are built from the working tree each run, so the suite validates the code you have checked out.
Requirements: bash, Docker + docker compose, curl, python3, and a Rust
toolchain with the WASM/WASI targets used by scripts/build-filters.sh. No
environment variables, secrets, or network access are required.
Note: with
AUTH_DISABLED=true, unauthenticated and unresolvable-credential requests are admitted as the demo user. Key expiry/revocation rejection therefore cannot be asserted against the shared gateway; the strict-auth feature (below) covers denial semantics on a second gateway instead.
Features
| Script | Concern | What it proves |
|---|---|---|
10-http-surface.sh | HTTP surface | /health; / serves the dashboard HTML and is not an S3 ListBuckets XML response; /openapi.json is OpenAPI 3.1 and documents /dashboard/api/keys and /dashboard/api/backend; /docs serves Swagger UI (following the redirect); retired /dashboard/api/demo/store returns 410 for every method |
15-avro-gate.sh | Avro gate | A PUT with Content-Type: application/avro is rejected (501) while MASKURA_ENABLE_AVRO is unset, and nothing is stored |
20-redaction-roundtrip.sh | Core redaction | maskura test upload stores a PII fixture through the pipeline; the object read directly back from MinIO contains [REDACTED_EMAIL]/[REDACTED_SSN]/[REDACTED_CARD] and no plaintext |
25-strict-auth-denial.sh | Auth enforcement | Boots a second, isolated gateway on $MASKURA_STRICT_GW_PORT (default 9011) without AUTH_DISABLED and an empty keystore: unauthenticated S3 PUT/GET/List are denied 403 and the dashboard key API is denied 401 — no demo fallback |
30-keys-s3-lifecycle.sh | Keys + S3 data plane | Dashboard key create / list / revoke happy paths (s4_/s4s_ formats, revoke returns 204 and removes the key); header-authenticated S3 PUT → HEAD → GET byte-identical read-back → ListObjects v1 and v2 via the real MinIO backend → DELETE → 404 |
40-plugin-admin-http.sh | Plugin management | Import a real .wasm component (201), catalog list, enable (200 + enabled: true), reorder, and remove (204) over the HTTP admin routes mounted in AUTH_DISABLED mode. Runs last because enabling an imported component can change the write pipeline |
Each feature prints PASS:/FAIL: lines and exits non-zero on failure, so a
feature can also be executed directly against an already-booted environment.
What the suite covers beyond the old e2e
The original e2e only exercised GET /health and one demo-mode test upload.
The suite now also asserts previously untested public paths:
- S3-backend
ListObjectsv1 + v2 and byte-faithful authenticated PUT/HEAD/GET/DELETE against a real S3 backend (MinIO). GET/DELETE /dashboard/api/keyshappy paths and the dashboard key format.- Dashboard HTML,
/openapi.json+/docsserving, and legacy tombstone 410s. - Plugin import / list / enable / reorder / remove across the real HTTP router.
- Data-plane + dashboard denial on a non-
AUTH_DISABLEDboot (no demo fallback). - The Avro enablement gate (negative path).
Deferred scenarios (and why they are separate harnesses)
The following need a different gateway boot contract or extra tooling, so they
are deliberately not part of this suite yet. Each is a natural future
NN-*.sh with its own boot:
- Positive Avro round trip and envelope/stable field encryption —
need
MASKURA_ENABLE_AVRO=true, an authenticated public key, OCF fixtures, and an Avro codec (e.g.fastavro) on the runner to assert typed output. See Avro OCF support. - Managed service storage — needs a multi-backend boot with
S4_SERVICE_BUCKETSand noS3_ENDPOINT(the two are mutually exclusive at startup). - Staged multipart — needs Postgres and a durable KEK
(
MULTIPART_MODE=staged+DATABASE_URL); covered today by the Postgres-gateddb_keys_testCI job. - Key expiry/revocation rejection on the data plane — needs an
auth-enabled boot that can create keys (with
AUTH_DISABLEDunset, the dashboard key API requires a real user session). - Presigned URL proxy — needs a container-network harness: the host-run gateway cannot deterministically reach the local MinIO loopback over IPv4 on all Docker setups, and the SSRF allowlist rejects IP-literal hosts.
- SDK / MCP live round trips — need the Python/TypeScript SDK or MCP runtime dependencies on the runner.
Adding a feature
- Create
scripts/e2e/features/NN-short-name.shthat sources../lib.sh, callsbegin_feature, asserts withexpect_status/assert_contains/assert_absent, and ends withend_feature "<name>". - Use unique object keys and, when mutating the gateway state, place the
feature last (as
40-plugin-admin-http.shdoes). - If the feature needs its own gateway, boot it on its own port and keys file
and stop it on
EXIT(see25-strict-auth-denial.sh). - Keep it deterministic in CI: only
bash,curl,python3, Docker, and the artifacts the orchestrator already builds. Remembercurl --data-binaryimplies POST unless-X PUTis given.
Troubleshooting
- If MinIO is already running on
:9000the orchestrator reuses it; give it a moment or confirmcurl http://127.0.0.1:9000/minio/health/livesucceeds. - The gateway log is written to the isolated run directory
(
$E2E_KEYS_DIR/gateway.log) and removed on exit; run a single feature and check theFAIL:lines for the exact assertion that broke.
Maskura security
This document describes the security model of the Maskura Gateway as it exists in the current streaming architecture (Phases 0–12): who authenticates, how API keys are handled at rest and in use, how objects are transformed and staged, what the trust boundaries are, what a production deployment must configure, and what Maskura explicitly does not guarantee. Anything not described here is not a guarantee.
For the vulnerability reporting process, see SECURITY.md.
1. Overview & trust boundaries
┌─────────────────────────────────────────────────────┐
Dashboard (browser) │ Maskura Gateway │
Supabase Auth/JWT ───▶ /dashboard/* (JWT-validated) │
│ │
Maskura SDK / CLI ────▶ S3 data plane ── Wasm pipeline ──▶ storage │
native S3 tools ──────▶ (SigV4 / API key) (streaming, fail-closed) │
│ (durable journal + staging for transactional paths)│
└─────────────────────────────────────────────────────┘
(S3/R2/B2/MinIO)
Trust boundaries:
- Identity — the data plane is authenticated by Maskura API keys. Requests are
accepted only when the SigV4 signature or the SDK header secret verifies
against a registered key; the dashboard is authenticated by Supabase Auth
(JWT).
AUTH_DISABLED=truebypasses auth and is a local-development-only mode. - Data in transit — HTTPS is required in production. SigV4 signatures are computed over-the-wire but the secret-bearing headers must not traverse plaintext HTTP.
- Data at rest — objects are stored on the configured backend after the plugin pipeline has run. Staged object bytes are encrypted before they touch the staging artifact store or disk. API key secrets are never stored in plaintext (see §4).
- The gateway is a router — it holds backend credentials only to re-sign requests to the configured backend. The zero-trust presigned-URL path forwards without storing any backend credential at all.
2. Authentication
Dashboard
- Supabase Auth (GoTrue); the gateway validates the access token JWT
(
SUPABASE_JWT_SECRET, with audience validation) for/dashboard/*routes. - Local/demo mode (
AUTH_DISABLED=true) bypasses auth entirely — do not use that flag outside local development.
S3 data plane — API keys
A Maskura API key is a pair s4_<32-hex> (access key ID) + s4s_<32-hex>
(secret), revealed once at creation. Two authentication paths:
- Maskura SDK header path — the client sends the plaintext secret in
x-maskura-access-key/x-maskura-secret-keyheaders orAuthorization: Bearer <access_key>:<secret>. The gateway comparessha256(secret)against the storedsecret_hash. Requires TLS. - Native S3 tools (SigV4) — the client signs each request with AWS SigV4 using its Maskura key. The gateway recomputes the signature and rejects requests whose signature does not match the stored secret.
The x-maskura-* customer headers are the only supported names. Reserved
metering, operation, and usage headers are rejected. Credential prefixes remain
s4_, s4s_, and s4m_ so existing credentials do not change.
SigV4 verification
Verification is header-first and pre-body: parsing, credential-scope checks, signed-header validation, timestamp checks, and canonical seed signature verification all complete before a request body is polled.
- Header auth —
Authorization: AWS4-HMAC-SHA256 Credential=…withx-amz-dateandx-amz-content-sha256(mandatory for header auth). - Query auth (presigned) —
X-Amz-*query parameters; presigned requests without an explicitx-amz-content-sha256header default toUNSIGNED-PAYLOAD, which is only accepted over trusted TLS. - Signed-header integrity —
hostis always signed, and header auth also requires signedx-amz-dateandx-amz-content-sha256. In every SigV4 mode, every presentx-amz-*header exceptx-amz-user-agentandx-amz-checksum-mode, plus each present request-semantic header (x-maskura-storage-mode,x-maskura-backend-url,x-maskura-process,x-maskura-stable-fields,x-maskura-encrypt-fields,content-type,content-encoding, andcontent-md5, plus their exact legacy aliases), must appear inSignedHeaders, occur exactly once, contain valid UTF-8, and already equal AWS SigV4 TrimAll form: no leading or trailing SP/HTAB and every internal SP/HTAB run collapsed to one ASCII space. The gateway rejects rather than rewrites noncanonical values, including comma-equivalent duplicates, so metadata and tagging consumers observe the same sole canonical value that was signed. The two exact exceptions are also exempt from duplicate and canonical value enforcement because the pinnedaws-sigv4presigner excludes them.x-amz-checksum-modeis read-only response-integrity negotiation, not a storage-selection or processing semantic. Optional headers may be absent, so a normal host-only presigned GET remains valid; violations receive the generic signature-mismatch response before the request body is read. - Scope validation — region (
S4_SIGV4_REGION, defaultus-east-1), service (s3), and terminator (aws4_request) are enforced; the request timestamp must fall within the clock-skew window (15 minutes) and presigned URLs cannot exceed 7 days. - Payload integrity — the request body is verified against the declared
payload hash and, for aws-chunked streaming, each chunk signature and the
x-amz-trailer-declared checksum trailer are verified incrementally with constant-time comparisons. Supported modes:UNSIGNED-PAYLOAD,STREAMING-AWS4-HMAC-SHA256-PAYLOAD,STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER,STREAMING-UNSIGNED-PAYLOAD-TRAILER, and a raw SHA-256 payload hash. - Checksums —
x-amz-checksum-*(CRC32, CRC32C, CRC64NVME, SHA-1, SHA-256) declared in headers or trailers are verified against the decoded body. Conflicting declarations, missing trailer declarations, duplicate trailers, trailing data after the final frame, and mismatched checksums are rejected. Decoded length must matchx-amz-decoded-content-length. - Signing-key cache — a bounded, TTL-based cache stores only derived signing keys and secret fingerprints; the plaintext secret exists in memory only transiently during verification and is never logged or persisted.
- SigV4 verification is skipped only in
AUTH_DISABLEDlocal mode.
3. API key handling at rest
Each key stores two artifacts:
secret_hash—sha256(secret)for the SDK header path. Not reversible.secret_encrypted— an envelope:v2:{base64(wrapped_dek)}:{base64(nonce)}:{base64(ciphertext+tag)}where the secret is AES-256-GCM encrypted under a fresh, per-key 256-bit data key (DEK), and the DEK is wrapped by aKeyWrappingimplementation (crates/gateway/src/key_cipher.rs). v2 envelopes bind the API key identity as AES-GCM additional authenticated data (AAD), preventing an encrypted secret from being moved to another key. v1 envelopes are accepted for legacy keys.
The plaintext secret exists only in the create-key HTTP response and, transiently, in memory during SigV4 verification. It is never logged or persisted.
Credential mutations are bounded before persistence. API-key and MCP labels are
trimmed, non-empty, free of control characters, and at most 128 UTF-8 bytes.
Non-zero API-key and MCP lifetimes are at most one year (0 means no expiry).
Encryption public keys are at most 16 KiB and must be a MASKURA HYBRID PUBLIC KEY PEM carrying an X25519 public key and an ML-KEM-768 encapsulation key,
matching the format consumed by the envelope-encryption filter. Credential JSON
endpoints also have route-specific body limits; oversized requests receive 413.
Credential creation returns plaintext only after the repository has committed the new credential. File-backed mutations remain hidden from concurrent readers until snapshot persistence succeeds, and failed mutations restore the prior state before readers resume. Repository failures are reported as service unavailable; they are never treated as a missing credential, an empty list, or an authentication denial.
The file-backed repository writes and synchronizes a permission-restricted temporary snapshot, then atomically renames it as the mutation commit boundary. It attempts to synchronize the parent directory after rename; failure is warned but cannot make the already-published snapshot uncommitted. This provides committed atomic visibility, not an absolute power-loss durability guarantee. Missing snapshots start empty; unreadable or invalid snapshots stop startup instead of being replaced. This repository is a single gateway-instance abstraction and does not provide multi-process locking.
Key-wrapping providers
| Provider | Durability | When to use |
|---|---|---|
LocalKeyWrapping (S4_SECRET_KEK) | Durable (operator-provided 32-byte KEK) | Local dev and self-host with a managed KEK |
| Ephemeral (no KEK configured) | Not durable (random in-memory KEK, lost on restart) | Local dev only |
| KMS/Vault-backed wrapping (injected) | Durable | Production |
build_state accepts an injected Arc<dyn KeyWrapping>. The OSS self-host
binary resolves S4_SECRET_KEK / ephemeral via key_cipher::default_wrapping();
a KMS/Vault-backed wrapper can be supplied by an embedding deployment without
changing the engine. is_durable() is the load-bearing flag: durable staging
fails closed when the active wrapping is not durable (see §7), because a lost
DEK would permanently strand staged tenant data.
SecretCipher::decrypt retains its public compatibility behavior and collapses
invalid data or wrapping failures to None. Credential repositories use
decrypt_result instead, allowing operational KMS/Vault failures to become a
generic service-unavailable response rather than an authentication denial.
Non-local deployments must use a durable KMS/Vault-backed wrapping. Running with auth enabled while the active wrapping is the local KEK or an ephemeral key is not secure for shared/non-local use. The gateway logs a prominent warning at startup in this configuration.
Legacy hash-only keys (created before envelopes existed) cannot verify SigV4 signatures — regenerate them if you need native S3 tools.
4. The plugin pipeline (data transformation)
- Plugins are Wasm components (
wasmtime, Component Model + WITs4:filter@0.1.0), 64 MiB aggregate guest memory per object, 10K table entries, 4 memories, 512 KiB max stack. No host imports beyond WASI stdout/stderr; the boundary is byte-in/byte-out. - A fresh
Storeand component instance per object (ADR 0007): no runtime state is shared across objects or tenants. Compiled components may be cached by hash; guest state (linear memory, globals, resources) is strictly per-invocation and dropped when the object completes. - Fuel and deadlines — per-call and cumulative fuel budgets
(
MASKURA_WASM_FUEL), a per-call wall-clock deadline (epoch interruption, default 30s) and an object deadline (default 5 minutes), plus cancellation tokens that interrupt active guest calls. - Admission control — streaming sessions run on a bounded worker pool with
a capped queue and a guest-memory budget (
MemoryAdmission); admission failure surfaces asSlowDownrather than unbounded parallelism. - The pipeline is deterministic and runs before data reaches storage on
the write path: redaction replaces PII, encryption (per-field hybrid X25519 +
ML-KEM-768 / AES-256-GCM) keeps the decryption key solely with the client.
stable-encrypt(AES-SIV) is opt-in for JOIN keys and derives from the API key secret, never the raw secret. - Every authenticated request resolves its user through the
WorkspaceStorageRepositoryto a canonicalWorkspaceId. Workspace IDs are opaque, used unchanged, and limited to 1–128 ASCII characters from[A-Za-z0-9._-]; a repository may map multiple users to one workspace. Resolution failure fails closed. - The canonical workspace ID, not the user ID, scopes backend configuration, managed placement, multipart identities and quotas, and usage metering. Object sessions remain isolated per invocation.
5. Transformed reads — fail-closed disclosure rules
Read-time processing (x-maskura-process: read) runs the pipeline on the way out:
the object in storage is unchanged, and the caller receives only the
transformed projection. This is the agent-safe-read path. The guarantees are
deliberately strict:
- Preflight from stored metadata. A transformed read performs a metadata
preflight against the stored object before any source body is consumed.
The preflight validates
Content-Typeagainst a known format (JSON/JSONL/CSV/TSV/text), rejectsRange,partNumber, non-identityContent-Encoding, and unknown mandatory formats, and requires stored metadata. Presigned-HTTP backends are rejected for transformed reads because they cannot provide a safe metadata preflight. - Version/ETag binding. The source GET must match the preflight: either an immutable version ID equality or a strong ETag equality (weak ETags are not accepted). If the source metadata changed between preflight and GET, the request is rejected — never silently served.
- No raw fallback. A pipeline error, decoder error, or staging failure fails the request closed. There is no fallback to raw bytes under any condition.
- Prefix-safe direct streaming only. When every component in the
pipeline snapshot is marked prefix-safe for read (operator-declared via
MASKURA_PREFIX_SAFE_COMPONENT_HASHESat process start — imported components are unsafe by default and the capability is immutable per component hash), the transformed output is streamed directly with bounded backpressure and no disk spool. Actual source/output bytes, fuel, duration, and component evidence are settled only after successful pipeline completion. EOF is withheld during bounded exact settlement retries. Cancellation before disclosure releases the reservation; cancellation, pipeline failure, or exhausted settlement after disclosure leaves the reservation for bounded control-plane recovery and never records successful customer usage. - Encrypted read spool otherwise. Any snapshot containing an unsafe
component is rejected unless
MASKURA_TRANSFORMED_READ_SPOOL=encryptedis set. The transformed output is then written to a disk spool in independently authenticated AES-256-GCM chunks under a key that lives only in the request task (a stale spool file is unreadable after a restart). The spool quota is reserved before source disclosure, file permissions are 0600, the response body is streamed from the spool only after the full representation is written, and a truncated or corrupted spool terminates the response body. Dropping the response body cancels the replay and releases the reservation. - No
HEADon transformed reads —HEADis rejected until transformed metadata is available. - Transformed reads require stored, version-bound metadata and work with S3, managed storage, and in-memory backends (in local mode). Presigned backend URLs remain raw-only.
MASKURA_STREAMING_READ_MODEgates the path:off(default) rejects transformed reads entirely;passthroughenables only raw streaming;transformedenables this path.
6. Storage backends
- Presigned URL proxy (
x-maskura-backend-url) — the user generates a presigned URL with their own cloud SDK; Maskura filters and forwards. No backend credential is stored. The gateway applies SSRF controls before any request is made (see §10). - Per-workspace backend config —
managed,s3_compatiblewith an endpoint, region, and static access credentials, oraws_role(IAM role ARN- region + optional
external_id) that is assumed via STS per request. Runtime S3-compatible endpoints are governed byWorkspaceEndpointPolicy(see §10), and dashboard reads return only a redacted configuration.
- region + optional
- Service storage — Maskura-managed multi-cloud buckets (
S4_SERVICE_BUCKETS), tenant-namespaced by workspace and optionally backed by authoritative placement metadata (see §9). - Resolution contract — an explicit
x-maskura-storage-mode: managedrequest is resolved first, followed by a presigned URL, a per-workspace configuration, and configured service storage as the default. Only explicit single-tenant mode (AUTH_DISABLED=trueorMASKURA_SINGLE_TENANT=true) may continue to the globalS3_ENDPOINTand then in-memory storage. - Multi-tenant fail-closed boundary — startup rejects
S3_ENDPOINTand requires non-emptyS4_SERVICE_BUCKETS. An unconfigured workspace therefore uses managed storage; workspace repository failures and unavailable explicit or workspace-managed selections never fall through to a process-global backend.
Multipart uploads are not buffered in gateway memory: parts are staged durably and encrypted before any downstream processing (see §7).
7. Multipart staging — durable, encrypted, fenced
MASKURA_MULTIPART_MODE=staged (default reject) enables client multipart uploads
through a durable staging subsystem:
- Durable encrypted staging. Each part is written to a local artifact file
(
S4_MULTIPART_STAGING_DIR) framed asS4MP10magic + JSON header (containing the wrapped DEK, tenant/upload/part identity, and a digest of the multipart snapshot) followed by AES-256-GCM chunks whose AAD binds the header and chunk number. The artifact is then copied to a dedicated Maskura-controlled object store (S4_MULTIPART_STAGING_BUCKET/ENDPOINT/credentials). The encryption key never exists in the gateway beyond the request lifetime, and the DEK is wrapped by the configuredKeyWrapping. is_durablerequirement. Staged multipart requires a durable wrapping (KMS/Vault or a configuredS4_SECRET_KEK); an ephemeral wrapping causes staging to fail closed. It also requiresDATABASE_URL(durable repository) and the complete staging backend configuration.- Durable quota reservations. Per-tenant and global staging quotas
(
S4_MULTIPART_STAGING_TENANT_QUOTA_BYTES/_GLOBAL_QUOTA_BYTES) are reserved in Postgres with row locks before any body frame is consumed, temp file opened, or artifact created. Crash-consistency is handled by a pending-outbox (begin/commit/discard) state machine that reconciliation replays. - Snapshot binding. The multipart snapshot (metadata, tags, checksum mode, destination, plugin snapshot, limits) is recorded at initiation; completion replays each artifact through a reader that authenticates the envelope identity, snapshot digest, and AEAD tags before any frame is exposed, and verifies the replayed part against the committed ETag/checksum/size.
- Completion fencing.
CompleteMultipartUploadis serialized by a durable completion lease with a fencing token and a request fingerprint. Idempotent retries replay the stored result; a stale worker that loses its lease is fenced (its renewals and any abort are rejected) — a fenced completion returnsServiceUnavailableinstead of corrupting state. Takeover is explicit: a new worker acquires the lease with its own token, and the old worker cannot abort. - Expiry and reconciliation. Expired uploads are reaped by a background worker (aborting the destination and cleaning staged parts); orphaned artifact files are removed by startup and periodic cleanup; pending outbox entries are reconciled against the artifact store.
8. Transactional writes — journal, atomic commit, reconciliation
Streaming writes (single-part PUT, and staged multipart when
MASKURA_MULTIPART_MODE=staged) run through a durable transaction layer:
- Operation journal. A Postgres-backed
OperationJournal(DATABASE_URL) records each operation’s state machine:INTENT → OPEN → COMPLETING → COMMITTED, withCOMMIT_UNKNOWNandPROVEN_ABORTEDfor crash recovery. Streaming writes in a non-development deployment require a durable journal; there is no in-memory fallback. - Atomic commit. The transformed output is fully verified (decoded length, SHA-256 of the emitted bytes, per-part ETags/checksums) against the expected object before the destination commit is issued. On any failure the sink is aborted and the destination multipart upload is cleaned up.
COMMIT_UNKNOWNreconciliation. When a completion outcome is ambiguous (the request failed aftercompletewas issued), the operation transitions toCOMMIT_UNKNOWN. Request-scoped cleanup may reconcile only that exact operation using the backend bound to the request. Per-user operations that cannot be safely reprobed remain pending for an external reconciler with the original credentials; one request never scans or probes another tenant’s operations.- Launch sink boundary. Direct S3 output is streamed as a multipart upload. Managed and presigned-HTTP streaming writes are rejected before request-body polling or multipart staging until their commit evidence can share the same authorization identity and reconciliation guarantees.
9. Managed replication — authority and repair fencing
S4_MANAGED_STREAMING_MODE (default off; observe/enforce require
S4_MANAGED_STREAMING_TRANSACTIONAL=true and a durable repository) adds
authoritative metadata over the consistent-hash placement:
- Placement. Deterministic rendezvous hashing (versioned,
S4_MANAGED_PLACEMENT_VERSION) selects a primary and one replica backend per logical object, independent of backend input order. - Authority. An
ObjectAuthorityrow records generation, digest, size, metadata, and primary/replica copy status with compare-and-swap semantics (cas_version); concurrent writers cannot silently overwrite authority. - Repair fencing. Replication repairs are durable records claimed with a
lease owner and token; renewal, completion (compare-and-swap), and failure
are all token-checked, so a stale worker cannot apply or duplicate a repair
after its lease is taken over.
validate_moderefuses to turn managed mode off after authority rows exist, and refusesobserve/enforcewithoutDATABASE_URL.
10. Outbound requests — SSRF, DNS, redirect, expiry, address pinning
Presigned-URL handling (PresignedHttpPolicy) applies the following before a
single byte is fetched or sent:
- Host allowlist — the URL host must be in
S4_PRESIGNED_HTTP_ALLOWLIST(supports*.suffixwildcards). - Scheme — HTTPS is required.
S4_PRESIGNED_HTTP_ALLOW_HTTP=truepermits HTTP only for an explicit presigned sourceGET; presignedPUTandDELETEdestinations remain HTTPS-only. - No userinfo or fragments, and the scheme must be supported.
- Expiry validation — the URL must carry an explicit expiry
(
X-Amz-Date+X-Amz-ExpiresorExpires) with at leastS4_PRESIGNED_HTTP_MIN_VALIDITY_SECS(default 30s) remaining. - DNS + address pinning — the host is resolved at request time; any
non-public address range rejects the request unless the host is in
S4_PRESIGNED_HTTP_PRIVATE_ALLOWLIST. The resolved address is pinned on the client (no re-resolution) and proxying is disabled. - No redirects — redirects are disabled at the client and redirect responses are rejected outright on reads.
Persisted S3-compatible workspace endpoints use a separate
WorkspaceEndpointPolicy; presigned URL policy and expiry rules are not reused:
- In multi-tenant mode HTTPS is mandatory, URLs cannot contain userinfo, query,
or fragment components, and the host must exactly match or be a strict
dot-boundary
*.suffixentry inS4_WORKSPACE_ENDPOINT_ALLOWLIST. - DNS is revalidated before every per-workspace AWS SDK client is constructed. Empty answers, private/reserved addresses, mixed public/private answers, IP-literal allowlist bypasses, and IPv4-mapped private IPv6 are rejected.
- The per-workspace AWS SDK connector explicitly disables proxies and does not use browser-style redirect following. It performs its own DNS lookup after validation rather than using the validated addresses, so the multi-tenant allowlist is a trusted-provider boundary: operators must allow only provider domains whose DNS tenants cannot control or rebind.
- Every gateway AWS SDK S3 client is configured for one SDK attempt. Journaled and spooled write transactions retain their own explicit bounded retry budget (currently three attempts), so retries remain visible to transaction evidence, fencing, and reconciliation rather than occurring inside the SDK.
- HTTP or private addresses are accepted only in explicit single-tenant mode.
Private destinations additionally require an exact operator entry in
S4_WORKSPACE_ENDPOINT_PRIVATE_ALLOWLIST; wildcard private exceptions are invalid. - Public builds provide no common-provider allowlist defaults. Deployments must choose the provider domains they trust.
- Endpoint-family recognition and streaming authority are separate. The public
engine recognizes only explicit AWS S3 global, known regional, legacy
regional, external-1, dualstack, and FIPS data-plane forms, plus GCS XML/HMAC,
B2 S3, R2, DigitalOcean Spaces, and Wasabi origin grammars. Arbitrary
s3-*labels and AWS website, control, Object Lambda, Outposts, accelerate, access-point, and virtual-hosted bucket forms are not accepted. Recognition never grants capabilities. - Hosted direct streaming requires an adapter-supplied immutable config version and operator capability/permission attestation. B2 additionally requires version listing/deletion and exact-version recovery. Missing attestations or legacy repository implementations fail closed.
- Before body polling (including Avro OCF) or provider mutation, the adapter must atomically acquire a database routing lease bound to operation ID, config version, attestation, and routing epoch. A background heartbeat covers body processing, and every provider future is directly raced against renewal after an immediate fence assertion. Fence loss drops that future and forbids later probes, aborts, or mutations. Only committed or proven-aborted operations release the lease. Config transitions and version retirement must conflict while a lease or nonterminal journal row is open.
- Journal rows store only opaque version/attestation IDs and routing lease/fence
values, never credentials. Startup and periodic reconcilers call
reconcile_workspace_streaming_operation. Nonterminal recovery claims the journal row first, then performs an expired-only CAS over the persisted lease ID/token/config/attestation/epoch. The private adapter advances both the route fence and journal binding in one transaction before historical credentials are loaded and the same client is reconstructed. There is no fallback to current credentials and an active route lease is never stolen. - Journal terminal state precedes route settlement. This ordering preserves the
authoritative provider outcome across crashes. The startup/periodic pass also
visits workspace-bound
COMMITTEDandPROVEN_ABORTEDrows and idempotently settles their exact lease after atomically verifying the matching journal outcome, closing the crash window between journal commit and request cleanup. - Private implementation boundary: the SaaS repository owns encrypted, immutable config-version retention, provider conformance attestation, atomic lease/config-transition transactions, lease recovery, and scheduling the public reconciliation hook. Until it implements every new repository method, hosted PerUserS3 direct streaming remains disabled by rejecting defaults.
- Process-global
S3_ENDPOINTstorage still requires explicitMASKURA_STREAMING_S3_PROVIDER. A non-durable journal is accepted only in a debug build with both auth disabled and explicit single-tenant mode; release and hosted/global production paths require a durable journal.
11. Cancellation and cleanup
- Cancellation. Every streaming operation carries a cancellation token that
propagates through the Wasm pipeline, the source body, the sink, and spool
replays. Dropping a response body cancels the source and pipeline; a
cancelled guest call is interrupted via the epoch engine and surfaced as
WASM_CANCELLED. Request aborts never issue destination aborts from a stale worker (fencing checks first). - Cleanup/reconciliation workers. Startup and periodic jobs remove stale
compatibility spool files, orphaned encrypted multipart spool files,
reconcile pending staging outboxes against the artifact store, reap expired
multipart uploads, and reconcile managed repairs. Direct
COMMIT_UNKNOWNoperations require exact-operation reconciliation with the immutable backend config version and routing fence originally bound to the request. The same pass revisits terminal workspace operations until route settlement succeeds; terminal settlement is idempotent and performs no provider request.
12. Bounded parsing and memory
- The legacy 16 MiB whole-object buffering path was removed (Phase 12):
there is no whole-object memory buffer on the data plane. The gateway runs
fixed-RSS streaming: source frames are bounded by
MASKURA_SOURCE_MAX_FRAME_BYTESand decoded object bytes byMASKURA_MAX_OBJECT_BYTES; per-record decoder limits apply; andMASKURA_LEGACY_MAX_OBJECT_BYTESis no longer load-bearing. CompleteMultipartUploadXML is capped at 1 MiB and parsed by a strict grammar parser (no general XML resolver): DTDs and entities are rejected before tokenization, parts must be sorted and unique, and part ETags/checksums are validated against staged parts.- aws-chunked framing is decoded with fixed-size state and explicit limits; oversized frames, duplicate trailers, and trailing data are rejected.
- Wasm guest memory is capped per object (64 MiB aggregate) with a bounded worker pool and memory budget (see §4).
13. Logging prohibitions
The gateway must never log:
- Object bytes or transformed payloads (neither plaintext nor ciphertext object content),
- Staging keys or DEKs (wrapped or unwrapped),
- Credentials — API key secrets, backend access keys, KEKs, or tokens,
- Backend endpoint URLs containing query data (these are rejected before persistence or client construction),
- Signed URLs (presigned URLs are bearer credentials),
- Staging artifact ciphertext or key material.
Operational logs may reference keys, buckets, user IDs, and error messages that do not embed the above.
14. Deployment responsibilities
These are operator responsibilities; Maskura will not and cannot enforce them from inside a container:
Customer-configurable gateway settings use MASKURA_* environment variables.
Internal/operator controls such as S4_SECRET_KEK,
S4_SERVICE_BUCKETS, S4_WORKSPACE_ENDPOINT_*, S4_PRESIGNED_HTTP_*,
S4_SIGV4_*, S4_MANAGED_*, and S4_MULTIPART_STAGING_* keep their existing
names and are not exposed through customer aliases.
- TLS termination — place the gateway behind a TLS-terminating proxy
(platform load balancer, ingress, or reverse proxy). SigV4 is signed
over-the-wire, but the SDK header path sends secrets in headers, so HTTPS is
required for the secret-bearing headers. Set
S4_SIGV4_TRUSTED_TLS=trueif your proxy terminates TLS and the gateway sees HTTP. - Trusted proxy — restrict access to the gateway to the trusted proxy (or bind the listener accordingly); do not expose it directly on the internet without TLS.
- KMS/Vault readiness — configure a durable
KeyWrapping(KMS or Vault) for any non-local deployment.S4_SECRET_KEKis durable but operator-managed plaintext; the ephemeral wrapper loses all wrapped secrets on restart. Durable multipart staging fails closed without a durable wrapping. - Durable journal / staging + Postgres — set
DATABASE_URL. Streaming writes require the durable operation journal; staged multipart requires Postgres plus the completeS4_MULTIPART_STAGING_*configuration; managed observe/enforce requires Postgres and transactional capabilities. - Backend lifecycle permissions — the backend credentials Maskura uses for direct/managed streaming must be able to create, abort, and discover multipart uploads, complete uploads, and (for reconciliation) perform conditional reads/HEAD. The capability gate refuses streaming eligibility without incomplete-upload discovery, abort, completion reconciliation, and a cleanup SLA within five minutes.
- Feature-gate defaults — transformed reads and managed streaming are
off/reject by default and must be explicitly enabled:
MASKURA_STREAMING_READ_MODE=off,MASKURA_MULTIPART_MODE=reject,S4_MANAGED_STREAMING_MODE=off. Single-part streaming writes are always enabled; staged multipart additionally requiresMASKURA_MULTIPART_MODE=stagedplus the durable staging dependencies. Enabling a gated feature without the corresponding durable dependencies causes startup to refuse configuration rather than silently degrade. - Outbound credentials — the global
S3_ENDPOINTclient accepts staticS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEYand, when those are absent, falls back to the AWS default credential provider chain (EC2 instance profile, ECS task role, EKS IRSA, SSO, OIDC web identity). Per-workspaceaws_rolebackends assume an IAM role via STS: temporary credentials are used once per request, never serialized, and the role ARN plus an optionalexternal_idform the trust boundary (see ADR). - Self-host hardening — run the container as non-root; limit egress to the
configured backends, KMS/Vault, and Supabase; do not ship
MASKURA_KEYS_FILEorkeys.jsonin the image; do not setAUTH_DISABLEDin production; put the spool and staging directories on private, capacity-reserved volumes sized forMASKURA_SPOOL_MAX_OBJECT_BYTES/MASKURA_SPOOL_QUOTA_BYTES(including encrypted framing overhead). - Dependency/update policy — track releases and apply security fixes
promptly; run
just deny(cargo-deny) andjust audit(cargo-audit) in your pipeline and keep the pinned toolchain (Rust 1.97.0) and Wasmtime version current. Only the latest stable minor is supported (see SECURITY.md).
15. Non-guarantees — the data-plane vs. provider distinction
Maskura is a data-plane processing gateway, not a storage provider. Maskura does not secure the third-party storage it is pointed at:
- Maskura does not control bucket policies, server-side encryption, access logs, retention, replication, or deletes on the destination. Configure those on the destination itself.
- Maskura forwards to the destination exactly the transformed representation and relies on the destination’s credentials/permissions for access control. Misconfigured destination credentials, world-readable buckets, or missing destination-side encryption are outside Maskura’s control and are not Maskura vulnerabilities.
- Maskura does not secure the identity provider (Supabase) or the email/analytics services used by the dashboard.
- Client-side decryption keys (for per-field encryption) and stable-encrypt keys are held by the client; Maskura never holds them.
ADR 0001: WebAssembly Component Model and WIT Contract
- Status: Accepted
- Date: 2026-08-09
Context
Maskura needs a contract between the gateway host and tenant-supplied filter plugins. The options considered:
- Core Wasm C ABI (pointer + length): Host and guest exchange raw linear memory pointers. Fragile, no type safety, manual memory management on the guest side.
- WASI command model (wasm32-wasip1): Treats each invocation as a separate process with stdin/stdout. Can’t maintain state across records within a single object stream.
- WebAssembly Component Model with WIT: Typed interface definitions, stateful sessions, automatic memory management via
list<u8>.
Decision
Use the Component Model and a versioned WIT world (package s4:filter@0.1.0). The guest exports begin, transform, and finish functions with typed parameters. Stateful per-object sessions allow filters to accumulate context across records.
Consequences
- Host side uses wasmtime’s component model bindgen for typed function calls.
- Guest side uses wit-bindgen for idiomatic Rust trait implementations.
- Shared WIT file is the single source of truth for the data-plane contract.
- Fresh
Storeper object provides strong isolation between requests.
ADR 0002: AWS Nitro Enclaves, TLS-in-Enclave, us-east-1
- Status: Accepted
- Date: 2026-08-09
Context
Maskura’s core value proposition is operator-resistant confidentiality: plaintext and secrets must be hidden from Maskura operators and cloud host administrators. The trusted execution environment must:
- Not expose plaintext to the parent EC2 instance.
- Terminate TLS inside the enclave (parent is an opaque byte relay).
- Support remote attestation for customer verification.
- Be available in a region with reasonable latency for US customers.
Decision
Use AWS Nitro Enclaves with TLS termination inside the enclave (ACM for Nitro Enclaves with NGINX/PKCS#11). Deploy in us-east-1 initially. Parent EC2 runs a minimal TCP/vsock relay that never sees plaintext. KMS enforces PCR-based attestation conditions before releasing gateway secrets.
Consequences
- No Cloudflare proxying for data-plane traffic (would expose plaintext outside the enclave).
- ARM Graviton instances (
m7g.xlarge) preferred for cost/performance; validate arm64 Wasmtime before provisioning. - Local development uses a virtualized parent/enclave pair with clearly marked dev attestation roots.
- Documentation must record PCRs and release manifests for customer verification via
maskura.
ADR 0003: Canonical CBOR and Ed25519 Policy Manifests
- Status: Accepted
- Date: 2026-08-09
Context
Tenant-defined pipeline configuration (destinations, routes, filters, limits) must be verifiably authentic. Maskura operators must not silently change policy. The signed manifest is the customer’s attestable statement of intent.
Options considered:
- JSON with detached signature: Non-deterministic whitespace and key ordering make verification fragile.
- Protobuf/FlatBuffers: Binary formats with deterministic encoding, but separate schema tooling adds complexity.
- Canonical CBOR with Ed25519: Deterministic map ordering, compact binary encoding, and well-supported Rust libraries.
Decision
Use canonical CBOR encoding with lexicographically sorted map keys and Ed25519 signatures. The manifest body is encoded deterministically, then signed. The resulting SignedManifest carries the canonical body bytes, signer ID, and signature. Verification validates the signature against trust roots and checks expiry/version monotonicity.
Consequences
- Trust roots are tenant-managed Ed25519 public keys provisioned during onboarding.
- Policy updates require a new signature; the dashboard can draft but
maskurasigns and activates. - Manifests have short expiry; monotonic version warnings on stale manifests discourage rollback.
- Implementation uses
ciboriumcrate with BTreeMap-based canonical serialization.
ADR 0004: Postgres Relational Source of Truth
- Status: Accepted
- Date: 2026-08-09
Context
Maskura needs a control-plane data store for users, workspaces, destinations, policies, API keys, usage records, and audit events. Options:
- Cloudflare D1: Global SQLite but limited relational constraints and per-request latency variance.
- Durable Objects: Good for stateful coordination but not a relational store.
- KV/Queues: Not suitable for relational queries, authorization, or billing integrity.
- Supabase Postgres: Full relational model, RLS for multi-tenant isolation, authentication integration.
Decision
Use Supabase Postgres as the sole relational business data store. No JSONB for application state; only for opaque payloads (Paddle webhooks, signed manifests, audit details). Store money as integer minor units, byte usage as BIGINT.
Consequences
- RLS enforces workspace isolation; service-role credentials exist only in server-side Worker secrets.
- Migration workflow is expand/contract, backward compatible across one version.
- Avoid D1, Durable Objects, KV, Workflows, and Containers until a demonstrated need exists.
- Database dependency for deployments means the local stack includes Supabase CLI for testing.
ADR 0005: Record Boundaries vs Transport Chunks
- Status: Accepted
- Date: 2026-08-09
Context
Input streams arrive in arbitrary byte chunks that may split records, UTF-8 code points, CSV fields, or JSON tokens. PII detection applied independently to each transport chunk would miss patterns spanning chunk boundaries.
Decision
Decouple record boundaries from transport chunks. Format-specific decoders assemble logical records from the byte stream before presenting them to Wasm filters. Record assembly must be UTF-8 code-point safe and handle chunk boundaries across all supported formats (JSONL lines, CSV quoted fields, JSON tokens).
Consequences
- Every chunk split of the same input must produce identical output and counters. Property tests verify this invariant.
- Decoder state is per-object; no cross-object state.
- Record boundaries for text/JSONL are lines; for CSV, quote-aware line splitting; for JSON, the entire document is one record in MVP.
- Chunk-size invariant property tests are required in CI from Phase 1 onward.
ADR 0006: Dev Attestation Never Production-Valid
- Status: Accepted
- Date: 2026-08-09
Context
The local development environment simulates the Nitro Enclave topology using Docker containers. It must be impossible to accidentally trust a dev attestation as production-valid.
Decision
The dev attestation provider uses a private CA, clearly marked root certificate, and UNTRUSTED DEVELOPMENT ATTESTATION labels in all tools and outputs. The production attestation chain (AWS Nitro root, KMS PCR conditions) is never present in dev images.
The transport protocol, secret-provider trait, and attestation-provider trait are identical between dev and production, but their implementations are swapped. maskura and the dashboard prominently label dev attestations and refuse to treat dev PCRs as production-approved.
Consequences
- Docker Compose local stack uses the same gateway binary but different provider implementations.
- No production signing keys, TLS certificates, or KMS configurations exist in the repository.
- CI gates prevent dev attestation data from entering production configuration.
ADR 0007: Fresh Wasmtime Store Per Object
- Status: Accepted
- Date: 2026-08-09
Context
Wasm modules from different tenants share the same gateway process. A cross-tenant state leak would break the confidentiality guarantee. Wasmtime’s Store holds instance state, including linear memory and mutable globals.
Decision
Create a fresh Store and component instance for each S3 object. Never reuse a Store or instance across objects. Compiled components may be cached by hash (immutable static code), but runtime state (Store, linear memory, globals, resources) is strictly per-invocation.
Consequences
- Sandbox limits are set per-object: fuel, epoch deadline, memory, stack, hostcall transfer limits.
- Multiple concurrent objects each get their own Store, instance, and sandbox state.
- After each object completes, the Store is dropped, releasing all instance memory.
- No mutable guest state is shared across objects or tenants. Cross-tenant pooling is deferred until zeroization can be demonstrated.
- Wasmtime’s
Storelifetime documentation confirms resources are not released until the Store is dropped, matching the per-object model.
ADR 0008: Keyless AWS credentials, aws_role backends, and bootstrapped gateway keys
- Status: Accepted
- Date: 2026-09-06
Context
The gateway historically required static long-lived credentials for every S3
destination: S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY for the global
single-tenant client, and a persisted s3_compatible access/secret pair for
per-workspace backends. BackendType::AwsRole existed in the schema but was
rejected at configuration time. Static keys are a security liability and are
awkward to rotate, and AWS-native deployments should be able to rely on
short-lived, automatically-refreshed credentials instead.
At the same time, headless automation had no stable gateway credential: the only key material was either generated at startup (demo mode) or minted interactively, so operators had to copy a freshly generated secret on every cold start.
Decision
-
Default credential provider chain for the global client. When
S3_ENDPOINTis configured without staticS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY, the global single-tenant client is built without an explicitcredentials_provider, deferring to the AWS default chain (EC2 instance profile, ECS task role, EKS IRSA, SSO, OIDC web identity). Static keys still take precedence when both are present. -
aws_roleper-workspace backends via STS.RuntimeBackendConfig::AwsRolecarries a role ARN, region, and optionalexternal_id. On resolution the gateway assumes the role withaws-sdk-sts(using the default credential chain), then builds the destination client from the returned temporary credentials (access key, secret, session token, expiry). The destination is always the canonical AWS regional endpoint (https://s3.<region>.amazonaws.com), so the operator endpoint allowlist does not apply; the trust boundary is the role ARN itself plus the identity allowed to assume it, reinforced by the optionalexternal_id. -
Bootstrapped gateway key.
MASKURA_BOOTSTRAP_KEY/MASKURA_BOOTSTRAP_SECRET(with permanentS4_*aliases) seed a preconfigured key id/secret pair at startup when the pair is not already present. The secret is SHA-256 hashed and encrypted with the same envelope as generated keys. This is scoped to operator/headless bootstrap; interactive and production flows keep using the normal key-creation path.
Consequences
- No long-lived AWS keys in the keyless path. Credentials resolve lazily via the ambient AWS identity, so rotation is handled by the platform (IRSA, SSO, instance profile) rather than by redistributing secrets.
- STS assume-role happens per request. Temporary credentials are used for a single operation and are never serialized or persisted. This trades a per-request STS call for the absence of any credential cache; a short-TTL in-process cache is a follow-up if request volume justifies it.
aws_roleconfig is validated at the boundary. Role ARNs must bearn:...:role/..., a region is required, and static credentials/endpoints are rejected for this backend type. The redacted dashboard response exposesexternal_id(it is a correlation value, not a secret) but never the temporary credentials.- Schema change.
BackendConfigRequestandBackendConfigResponsegain anexternal_idfield, requiring SDK regeneration viajust build-sdks. - Bootstrapped keys are operator-managed. They are printed nowhere at startup and rely on the operator rotating them out of band; they must not be used in place of per-user keys where attribution matters.
ADR 0009: Durable Async Write Acknowledgements
- Status: Accepted
- Date: 2026-09-07
Context
Maskura currently acknowledges an object write only after filtering and the authoritative storage commit finish. This synchronous contract is simple and durable, but its latency includes the complete transform and provider path.
Two planning documents proposed incompatible meanings for asynchronous writes.
One used full-async for an in-memory, fire-and-forget operation acknowledged
before transformation. The other required every hosted asynchronous
acknowledgement to identify a durable, recoverable write job. Reusing async
for both contracts would make a successful response ambiguous and could cause
clients to mistake accepted-but-losable work for a durable write.
Decision
Maskura has three write acknowledgement modes:
syncis the default. The existing S3 success response remains final and is returned only after the configured storage commit policy publishes the authoritative object generation.half_asyncreturns202 Acceptedonly after transformed output, its authenticated encryption metadata, the immutable target plan, and the operation’s ordering fence are durably persisted.asyncreturns202 Acceptedonly after source bytes or an equally durable source reference, the immutable transform and target plan, and the operation’s ordering fence are durably persisted.
Both asynchronous modes return a stable job ID and operation ID. An authenticated status resource is authoritative; resumable events may mirror durable state but cannot replace status lookup. Workers use leases and fencing, preserve receive order for one object key, publish at most one authoritative generation, and recover accepted work after process or machine failure.
Metering and billing occur exactly once after authoritative commit, not when a job is accepted. Failed, cancelled, or abandoned jobs are not billed. Retry is keyed by operation and target identity and may not create another visible version.
The canonical request policy name is x-maskura-write-mode; the permanent
compatibility alias is x-s4-write-mode. A request may only select a mode
allowed by authenticated workspace policy. DELETE remains synchronous until
its post-acknowledgement semantics receive a separate decision.
Hosted Maskura does not provide lossy fire-and-forget writes. If a self-hosted
best-effort queue is ever added, it must be named best_effort, be disabled by
default, and use a response contract that cannot be confused with durable job
acceptance.
Consequences
- A
202from Maskura means the write can be recovered without relying on the accepting process or machine. - Async modes require encrypted durable staging, normalized job and target state, quota admission, retry/dead-letter policy, status retention, and cancellation semantics before either mode can be enabled.
- Existing S3 clients remain on
syncand retain their current final-response behavior. - The lower-latency
half_asyncmode requires transformation to finish before acknowledgement;asyncshifts transformation to workers but consumes durable source-staging capacity. - In-memory fire-and-forget behavior cannot be marketed or configured as an asynchronous durability mode.
ADR 0010: Workspace-bound principals and hosted MCP transport boundary
- Status: Accepted
- Date: 2026-09-07
Context
API keys and MCP tokens were historically owned only by a dashboard user. On each data-plane request the gateway authenticated the credential and then asked the workspace repository to resolve that user’s current workspace. A durable credential could therefore move between workspaces when membership or default workspace selection changed. Existing rows contain no history from which the original workspace can be reconstructed safely.
The stdio MCP server also owned the tool request schemas, list result contract, and dispatch names. A hosted transport would otherwise duplicate those contracts or call the gateway through loopback HTTP with a synthesized credential header. The latter would turn an internal trust decision into spoofable network input and introduce a second auth path.
Decision
API keys and MCP tokens retain user_id as dashboard ownership metadata and
gain an immutable workspace_id execution principal. MCP tokens also expose a
stable credential UUID and credential-policy identity as one atomic
authentication result. Creation resolves the user’s workspace once and
persists it with the credential. Authentication uses that persisted workspace
directly and never resolves a current/default workspace. Records with no valid
workspace binding fail authentication.
The migration leaves genuinely unbound credentials null. Existing hosted UUID bindings are converted to canonical text and preserved after removing their incompatible foreign key. Database triggers reject changes to a credential’s workspace after insertion.
Transport-independent MCP request schemas, result types, validation, tool
definitions, legacy aliases, dispatch, and S3 list parsing live in the small
maskura-mcp-protocol crate. The stdio binary consumes those types and
continues to call the network S3 surface with its configured credential.
Hosted adapters use s4_gateway::server::invoke_mcp. They provide an already
authenticated AuthenticatedMcpPrincipal, server operation UUID, typed tool
request, hard-bounded request/response limits, timeout, and cancellation token.
The gateway derives credential policy identity only from that principal, binds
operation UUID reuse to the complete canonical operation, and carries trusted
state in task-local storage unavailable to HTTP clients. The API accepts no
authentication, metering, backend, or presigned URL headers.
Consequences
- Credentials cannot silently follow a user into another workspace.
- Legacy unbound credentials fail closed and require rotation.
- Dashboard ownership and data-plane workspace scope remain separate facts.
- Hosted MCP uses the same authorization, pipeline, storage, transaction, and usage paths as S3 without opening a loopback listener.
- Text MCP bodies and responses have non-configurable hard ceilings. The private transport remains responsible for envelope and chunk preparse bounds.
- Cancellation reaches active Wasm work and waits for gateway settlement; provider SDK calls that do not expose cooperative cancellation may complete before the invocation returns its committed outcome.
ADR 0011: Post-quantum hybrid envelope (X25519 + ML-KEM-768)
- Status: Accepted
- Date: 2026-09-07
Context
Maskura’s encryption filters use envelope encryption: a fresh AES-256-GCM data key (DEK) per field, wrapped with the client’s public key so only the key holder can decrypt. Today the DEK is wrapped with RSA-OAEP (SHA-256). A cryptographically relevant quantum computer breaks RSA via Shor’s algorithm, exposing long-lived ciphertext to harvest-now-decrypt-later.
The symmetric layers are already post-quantum-safe: AES-256-GCM resists Grover
(~2^128 for a 256-bit key), as does the API-key SecretCipher and the KMS/Vault
wrapping. The only quantum-vulnerable primitive in the data path is the RSA-OAEP
key wrap.
Decision
Replace the RSA-OAEP DEK wrap with a hybrid X25519 + ML-KEM-768 key encapsulation:
- ML-KEM-768 (NIST FIPS 203), a post-quantum KEM, protects against a CRQC.
- X25519 ECDH, a classical KEM, protects against an undiscovered weakness in a young post-quantum scheme.
The construction is secure unless both are broken — the consensus position of the IETF hybrid key-exchange draft, NSA CNSA 2.0, and BSI. The two shared secrets are combined with HKDF-SHA256 into a single 32-byte DEK; the data cipher remains AES-256-GCM.
Scope is KEM-only: the data path contains no signatures, so ML-DSA and SLH-DSA are out of scope.
The envelope carries a new alg value: X25519+ML-KEM-768/AES-256-GCM.
Consequences
- New client key format (hybrid public/private keys). The WIT
public-key-pemconfig field is unchanged — it still carries a string. - Per-field wrap overhead grows from 256 B (RSA-2048) to 1120 B; the hybrid public key is 1216 B. Negligible for objects, meaningful for many-field records.
- Expected fuel reduction: ML-KEM encapsulation has no modular exponentiation, versus RSA-OAEP’s ~25M wasm instructions per wrap.
- Dual-alg read, single-alg write: existing RSA-OAEP envelopes remain decryptable; new writes are hybrid-only.
- Implementation is planned, not yet shipped.
Alternatives considered
- Pure ML-KEM-768 — post-quantum-safe but exposes the young-scheme risk alone; rejected in favor of hybrid.
- ML-KEM-1024 — AES-256 parity but ~1.5 KB keys and larger ciphertext; deferred.
- RSA-4096 — still broken by Shor’s algorithm; no post-quantum benefit.