Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions pg-compat-js/test/gate-teeth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,37 @@ test('a bumped wire version is reported once, before any ciphertext is opened',
},
);
});

test('a manifest promising the raw sender value is reported, not opened cleanly', async () => {
Comment thread
dobby-coder[bot] marked this conversation as resolved.
// `sample_set.rs` hands the sealer this value and the manifest promises
// `canonicalize` of it, so writing it back into the manifest is what a sealer
// that stopped canonicalizing on its way to the wire would have produced.
const RAW_SENDER = ' Sender@Sample.TEST ';

await withDamagedSet(
async (dir) => {
const path = join(dir, 'manifest.json');
const manifest = JSON.parse(await readFile(path, 'utf8'));
assert.equal(typeof manifest.sender.public.con[0].v, 'string');
manifest.sender.public.con[0].v = RAW_SENDER;
await writeFile(path, JSON.stringify(manifest));
},
(dir) => {
const failures = runCase(dir, WASM_READER, 'mem');
assert.ok(failures.length > 0, 'a mismatched sender policy was reported as opening cleanly');
assert.ok(
failures.every((f) => f.includes('public signing policy is')),
JSON.stringify(failures),
);
// One per recipient, each naming which one it was.
assert.ok(
failures.some((f) => f.includes('mem/alice')),
JSON.stringify(failures),
);
assert.ok(
failures.some((f) => f.includes('mem/bob')),
JSON.stringify(failures),
);
},
);
});
16 changes: 11 additions & 5 deletions pg-compat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,19 @@ stream-multi-segment.bin/.plain
would drift.
- `wireVersion`: the container version the bytes claim (`VERSION_V3`, `2`).
- `sender.public`: the policy the sender signed the *header* with, visible to
anyone who has the bytes. This is what a reader checks the header signature
against, so a JS reader needs it as much as a Rust one.
anyone who has the bytes. Recorded in its **canonical** form, while the sealer
is handed a deliberately non-canonical value (`sample_set.rs`'s `SENDER`): that
disagreement is what makes the field a test rather than a copy of the input, so
a canonicalization that stops reaching the wire goes red here. Both halves of
the gate compare the sender policy a reader recovered against this.
- `sender.private`: the policy the sender signed the *payload* with in the
`*-privsig` cases. Despite the name it is not a secret key; it is the claims a
reader may only see after decrypting. It is present in the manifest for every
set, but only the cases with `privateSigning: true` were sealed with it, so
check it against `privateSigning` rather than against the case list.
reader may only see after decrypting. Canonical for the same reason as
`sender.public`, and non-canonical in the sealer for a different attribute
type, because the sealer canonicalizes the two policies in two separate
statements. It is present in the manifest for every set, but only the cases
with `privateSigning: true` were sealed with it, so check it against
`privateSigning` rather than against the case list.
- `mode`: `"memory"` for `Sealer<_, SealerMemoryConfig>::seal` (what pg-wasm's
`seal()` produces), `"stream"` for the segmented container (what cryptify
stores). pg-js is stream mode in both directions — `toBytes()` seals with
Expand Down
162 changes: 161 additions & 1 deletion pg-compat/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Output};

use serde::Deserialize;
use serde_json::Value;

pub mod support_window;

Expand All @@ -42,10 +43,30 @@ pub struct Manifest {
pub wire_version: u16,
/// File holding the PKG parameters with the verifying key.
pub verifying_key: String,
/// The policies every container in the set was signed under.
pub sender: Sender,
/// The sealed containers.
pub cases: Vec<Case>,
}

/// The sender policies the manifest promises, in the canonical form the sealer
/// wrote them in.
///
/// Held as raw JSON rather than as a `Policy`: every pinned `pg-core` is a
/// distinct crate with a distinct `Policy` type, so naming one of them in the
/// shared manifest would tie it to a single reader. The comparison is
/// structural, through [`describe_policy`].
#[derive(Debug, Deserialize)]
pub struct Sender {
/// The policy the *header* signature was made under, visible to anyone who
/// has the bytes.
pub public: Value,
/// The policy the *payload* signature was made under in the `*-privsig`
/// cases. Present for every set; only checked for a case whose
/// `privateSigning` is true.
pub private: Option<Value>,
}

/// One sealed container plus everything needed to open and check it.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -156,6 +177,56 @@ pub fn describe_plaintext_mismatch(got: &[u8], want: &[u8]) -> String {
}
}

/// A policy as a comparable string: attributes in a fixed order, and a missing
/// value distinguished from an empty one.
///
/// Mirrors `describePolicy` in `pg-compat-js/src/failures.mjs`, so a mismatch
/// reads the same in either half of the gate. `con` is sorted here because the
/// order a reader hands the conjunction back in is not part of the wire
/// contract, and a reordering must not read as a break.
Comment thread
dobby-coder[bot] marked this conversation as resolved.
Outdated
pub fn describe_policy(policy: &Value) -> String {
let mut con: Vec<Value> = policy
.get("con")
.and_then(Value::as_array)
.map(|attributes| {
attributes
.iter()
.map(|a| {
serde_json::json!({
"t": a.get("t").cloned().unwrap_or(Value::Null),
"v": a.get("v").cloned().unwrap_or(Value::Null),
})
})
.collect()
})
.unwrap_or_default();
con.sort_by_cached_key(Value::to_string);

serde_json::json!({
"ts": policy.get("ts").cloned().unwrap_or(Value::Null),
"con": con,
})
.to_string()
}

/// Compare a signing policy a reader recovered against the one the manifest
/// says was signed.
///
/// Recovering the right plaintext is not enough on its own: `SignatureExt.pol`
/// travels in full and a published reader derives the signer's identity from it,
/// so this is where a canonicalization that stops reaching the wire shows up.
pub fn describe_policy_mismatch(what: &str, got: &Value, want: &Value) -> Option<String> {
let got = describe_policy(got);
let want = describe_policy(want);
if got == want {
return None;
}

Some(format!(
"{what} signing policy is {got}, manifest says {want}"
))
}

/// Open one case in a child process and turn its outcome into failure
/// messages.
///
Expand Down Expand Up @@ -268,7 +339,9 @@ macro_rules! reader {

use serde::Deserialize;

use crate::{describe_plaintext_mismatch, read_file, Case, Manifest};
use crate::{
describe_plaintext_mismatch, describe_policy_mismatch, read_file, Case, Manifest,
};

/// The crates.io version this module reads with.
pub const VERSION: &str = $version;
Expand Down Expand Up @@ -339,6 +412,53 @@ macro_rules! reader {
case.private_signing,
));
}
failures.extend(check_sender(&label, &verified, manifest, case));
}
}
}

failures
}

/// Hold the recovered sender identity to what the manifest
/// promises. The policies are serialized back to JSON because this
/// module's `Policy` is one pinned crate's type and the manifest is
/// shared by all of them.
fn check_sender(
label: &str,
verified: &VerificationResult,
manifest: &Manifest,
case: &Case,
) -> Vec<String> {
let mut failures = Vec::new();

match serde_json::to_value(&verified.public) {
Ok(got) => failures.extend(
describe_policy_mismatch("public", &got, &manifest.sender.public)
.map(|m| format!("{label}: {m}")),
),
Err(e) => {
failures.push(format!("{label}: serialize the public signing policy: {e}"))
}
}

// A `private` the reader did not surface is already reported as
// a presence mismatch by the caller, so only the both-present
// case is left to compare.
if case.private_signing {
if let Some(private) = &verified.private {
match (serde_json::to_value(private), &manifest.sender.private) {
(Ok(got), Some(want)) => failures.extend(
describe_policy_mismatch("private", &got, want)
.map(|m| format!("{label}: {m}")),
),
(Ok(_), None) => failures.push(format!(
"{label}: recovered a private signing policy, the manifest names \
none",
)),
(Err(e), _) => failures.push(format!(
"{label}: serialize the private signing policy: {e}",
)),
}
}
}
Expand Down Expand Up @@ -472,6 +592,46 @@ mod tests {
assert!(message.contains("got 0x58, expected 0x64"), "{message}");
}

/// The order a reader hands the conjunction back in is not part of the wire
/// contract, so a reordering must not read as a break.
#[test]
fn a_reordered_conjunction_is_not_a_mismatch() {
let one = serde_json::json!({"ts": 1, "con": [{"t": "a", "v": "1"}, {"t": "b"}]});
let other =
serde_json::json!({"ts": 1, "con": [{"t": "b", "v": null}, {"t": "a", "v": "1"}]});

assert_eq!(describe_policy(&one), describe_policy(&other));
assert_eq!(describe_policy_mismatch("public", &one, &other), None);
}

/// The non-canonical sender fixture (#355) turns on exactly this: the raw
/// value and the canonical one must not compare equal.
#[test]
fn a_non_canonical_value_is_reported_with_both_forms() {
let raw = serde_json::json!({"ts": 1, "con": [{"t": "e", "v": " Sender@Sample.TEST "}]});
let canonical =
serde_json::json!({"ts": 1, "con": [{"t": "e", "v": "sender@sample.test"}]});

let message = describe_policy_mismatch("public", &canonical, &raw)
.expect("a raw value must not match its canonical form");
assert!(
message.starts_with("public signing policy is "),
"{message}"
);
assert!(message.contains("sender@sample.test"), "{message}");
assert!(message.contains(" Sender@Sample.TEST "), "{message}");
}

/// A missing value is not an empty one: `Attribute.value` is an `Option`,
/// and `to_hidden` blanks a value to `""` rather than dropping it.
#[test]
fn an_absent_value_differs_from_an_empty_one() {
let absent = serde_json::json!({"ts": 1, "con": [{"t": "e"}]});
let empty = serde_json::json!({"ts": 1, "con": [{"t": "e", "v": ""}]});

assert!(describe_policy_mismatch("public", &absent, &empty).is_some());
}

#[test]
fn a_truncated_plaintext_says_so() {
let message = describe_plaintext_mismatch(b"abc", b"abcde");
Expand Down
Loading
Loading