---
id: vault/derivedkey-serialization
name: Implement always-redact DerivedKey serialization and reject redacted payloads on deserialize
status: completed
depends_on: [vault/irpc-removal]
scope: narrow
risk: medium
impact: component
level: implementation
---

## Description

Fix drift item #5: `DerivedKey` currently has dual serialization behavior — JSON
redacts the private key, but postcard (the binary format used by irpc) preserves
the raw bytes. ADR-025 dropped the postcard/remote path, so `DerivedKey` should
**always** redact on serialize and reject `"[REDACTED]"` on deserialize with an
explicit error.

### Current state

`protocol.rs` has `DerivedKey` with `#[derive(Serialize, Deserialize)]` (or
similar) that produces JSON redaction for JSON but preserves bytes in postcard.
The postcard tests in the test suite verify the binary round-trip.

### Target state

Per `docs/architecture/crates/vault/protocol.md` → Serialization Redaction:

`DerivedKey` must **not** derive `Deserialize` via `#[derive]`. It needs custom
`Serialize` and `Deserialize` impls:

**Custom Serialize** — always redacts `private_key`:
```rust
impl serde::Serialize for DerivedKey {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where S: serde::Serializer {
        use serde::SerializeStruct;
        let mut s = serializer.serialize_struct("DerivedKey", 3)?;
        s.serialize_field("key_type", &self.key_type)?;
        s.serialize_field("private_key", "[REDACTED]")?;
        s.serialize_field("public_key", &self.public_key)?;
        s.end()
    }
}
```

**Custom Deserialize** — rejects `"[REDACTED]"` with an explicit error:
```rust
impl<'de> serde::Deserialize<'de> for DerivedKey {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where D: serde::Deserializer<'de> {
        #[derive(serde::Deserialize)]
        struct DerivedKeyHelper {
            key_type: KeyType,
            private_key: Vec<u8>,
            public_key: Vec<u8>,
        }
        let helper = DerivedKeyHelper::deserialize(deserializer)?;
        if helper.private_key == b"[REDACTED]" {
            return Err(serde::de::Error::custom(
                "DerivedKey.private_key is \"[REDACTED]\" — redacted payloads \
                 cannot be deserialized. JSON round-tripping a DerivedKey is \
                 not supported (the private key is gone)."
            ));
        }
        Ok(DerivedKey {
            key_type: helper.key_type,
            private_key: helper.private_key,
            public_key: helper.public_key,
        })
    }
}
```

**Debug impl** — also redacts:
```rust
impl fmt::Debug for DerivedKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DerivedKey")
            .field("key_type", &self.key_type)
            .field("private_key", &"[REDACTED]")
            .field("public_key", &self.public_key)
            .finish()
    }
}
```

### Remove postcard tests

The postcard round-trip tests (which verified binary format preserved private
key bytes) are removed — ADR-025 dropped that path. The `postcard`
dev-dependency was removed in the irpc removal task (drift #4).

### Why custom impls instead of derives

A derived `Deserialize` would generate a default impl that conflicts with the
manual one, and would only fail incidentally (serde type mismatch: string vs
sequence), not with the explicit redaction-rejection error the spec requires.
The custom impl is required for the explicit error message.

### Scope

This task touches `protocol.rs` (the `DerivedKey` type, its serde impls, Debug
impl) and test files (remove postcard tests, add redaction tests). It depends on
the irpc removal task (drift #4) because both modify `protocol.rs`.

## Acceptance Criteria

- [ ] `DerivedKey` does not derive `Serialize` or `Deserialize` via `#[derive]`
- [ ] Custom `Serialize` impl always redacts `private_key` as `"[REDACTED]"`
- [ ] Custom `Deserialize` impl rejects `private_key == b"[REDACTED]"` with explicit error
- [ ] Custom `Debug` impl redacts `private_key` as `"[REDACTED]"`
- [ ] Postcard round-trip tests removed
- [ ] Unit test: JSON serialize produces `"[REDACTED]"` for `private_key`
- [ ] Unit test: JSON deserialize of a redacted payload returns an error (not a corrupted key)
- [ ] Unit test: `{:?}` on `DerivedKey` does not contain private key bytes
- [ ] `cargo test` succeeds
- [ ] `cargo clippy` succeeds with no warnings

## References

- docs/architecture/crates/vault/README.md — Known Source Drift table item #5
- docs/architecture/crates/vault/protocol.md — Serialization Redaction, Debug redaction
- docs/architecture/decisions/025-vault-local-only-dispatch.md — ADR-025 (resolves W8)
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014

## Notes

> The redaction is defense-in-depth for logging safety, not the primary control
> — the primary control is that `DerivedKey` never crosses the call protocol
> wire (ADR-014). ADR-025 dropped the postcard/remote path that previously
> preserved bytes in binary formats. The custom Deserialize impl is required
> because `#[derive(Deserialize)]` would conflict and not produce the explicit
> redaction-rejection error. Depends on irpc removal because both modify
> `protocol.rs`.

## Summary

Replaced `DerivedKey`'s derived `Deserialize` with custom serde impls. `Serialize`
now always redacts `private_key` as `"[REDACTED]"` (dropped the
`is_human_readable()` branch that preserved bytes in binary formats). Custom
`Deserialize` rejects `private_key == b"[REDACTED]"` with an explicit error
message. Added tests for redacted-payload rejection and debug-no-leak. All tests
pass; clippy clean. Merged to develop.