Skip to content

Commit

Permalink
age-plugin: Add labels extension to recipient-v1
Browse files Browse the repository at this point in the history
  • Loading branch information
str4d committed Aug 11, 2024
1 parent 62082a6 commit befd8b3
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 28 deletions.
3 changes: 3 additions & 0 deletions age-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to Rust's notion of
to 1.0.0 are beta releases.

## [Unreleased]
### Changed
- `age::plugin::Connection::unidir_receive` now takes an additional argument to
enable handling an optional fourth command.

## [0.10.0] - 2024-02-04
### Added
Expand Down
33 changes: 24 additions & 9 deletions age-core/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ impl std::error::Error for Error {}
/// should explicitly handle.
pub type Result<T> = io::Result<std::result::Result<T, Error>>;

type UnidirResult<A, B, C, E> = io::Result<(
type UnidirResult<A, B, C, D, E> = io::Result<(
std::result::Result<Vec<A>, Vec<E>>,
std::result::Result<Vec<B>, Vec<E>>,
Option<std::result::Result<Vec<C>, Vec<E>>>,
Option<std::result::Result<Vec<D>, Vec<E>>>,
)>;

/// A connection to a plugin binary.
Expand Down Expand Up @@ -205,23 +206,26 @@ impl<R: Read, W: Write> Connection<R, W> {
///
/// # Arguments
///
/// `command_a`, `command_b`, and (optionally) `command_c` are the known commands that
/// are expected to be received. All other received commands (including grease) will
/// be ignored.
pub fn unidir_receive<A, B, C, E, F, G, H>(
/// `command_a`, `command_b`, and (optionally) `command_c` and `command_d` are the
/// known commands that are expected to be received. All other received commands
/// (including grease) will be ignored.
pub fn unidir_receive<A, B, C, D, E, F, G, H, I>(
&mut self,
command_a: (&str, F),
command_b: (&str, G),
command_c: (Option<&str>, H),
) -> UnidirResult<A, B, C, E>
command_d: (Option<&str>, I),
) -> UnidirResult<A, B, C, D, E>
where
F: Fn(Stanza) -> std::result::Result<A, E>,
G: Fn(Stanza) -> std::result::Result<B, E>,
H: Fn(Stanza) -> std::result::Result<C, E>,
I: Fn(Stanza) -> std::result::Result<D, E>,
{
let mut res_a = Ok(vec![]);
let mut res_b = Ok(vec![]);
let mut res_c = Ok(vec![]);
let mut res_d = Ok(vec![]);

for stanza in iter::repeat_with(|| self.receive()).take_while(|res| match res {
Ok(stanza) => stanza.tag != COMMAND_DONE,
Expand Down Expand Up @@ -255,10 +259,19 @@ impl<R: Read, W: Write> Connection<R, W> {
if stanza.tag.as_str() == tag {
validate(command_c.1(stanza), &mut res_c)
}
} else if let Some(tag) = command_d.0 {
if stanza.tag.as_str() == tag {
validate(command_d.1(stanza), &mut res_d)

Check warning on line 264 in age-core/src/plugin.rs

View check run for this annotation

Codecov / codecov/patch

age-core/src/plugin.rs#L263-L264

Added lines #L263 - L264 were not covered by tests
}
}
}

Ok((res_a, res_b, command_c.0.map(|_| res_c)))
Ok((
res_a,
res_b,
command_c.0.map(|_| res_c),
command_d.0.map(|_| res_d),
))
}

/// Runs a bidirectional phase as the controller.
Expand Down Expand Up @@ -481,10 +494,11 @@ mod tests {
.unidir_send(|mut phase| phase.send("test", &["foo"], b"bar"))
.unwrap();
let stanza = plugin_conn
.unidir_receive::<_, (), (), _, _, _, _>(
.unidir_receive::<_, (), (), (), _, _, _, _, _>(
("test", Ok),
("other", |_| Err(())),
(None, |_| Ok(())),
(None, |_| Ok(())),
)
.unwrap();
assert_eq!(
Expand All @@ -496,7 +510,8 @@ mod tests {
body: b"bar"[..].to_owned()
}]),
Ok(vec![]),
None
None,
None,
)
);
}
Expand Down
7 changes: 7 additions & 0 deletions age-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ to 1.0.0 are beta releases.
- `impl age_plugin::identity::IdentityPluginV1 for std::convert::Infallible`
- `impl age_plugin::recipient::RecipientPluginV1 for std::convert::Infallible`

### Changed
- `age_plugin::recipient::RecipientPluginV1` has a new `labels` method. Existing
implementations of the trait should either return `HashSet::new()` to maintain
existing compatibility, or return labels that apply the desired constraints.
- `age_plugin::run_state_machine` now supports the `recipient-v1` labels
extension.

### Fixed
- `age_plugin::run_state_machine` now takes an `impl age_plugin::PluginHandler`
argument, instead of its previous arguments.
Expand Down
12 changes: 11 additions & 1 deletion age-plugin/examples/age-plugin-unencrypted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use age_plugin::{
};
use clap::Parser;

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use std::env;
use std::io;
Expand Down Expand Up @@ -104,6 +104,16 @@ impl RecipientPluginV1 for RecipientPlugin {
}
}

fn labels(&mut self) -> HashSet<String> {
let mut labels = HashSet::new();
if let Ok(s) = env::var("AGE_PLUGIN_LABELS") {
for label in s.split(',') {
labels.insert(label.into());
}
}
labels
}

fn wrap_file_keys(
&mut self,
file_keys: Vec<FileKey>,
Expand Down
3 changes: 2 additions & 1 deletion age-plugin/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {

// Phase 1: receive identities and stanzas
let (identities, recipient_stanzas) = {
let (identities, stanzas, _) = conn.unidir_receive(
let (identities, stanzas, _, _) = conn.unidir_receive(

Check warning on line 225 in age-plugin/src/identity.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/identity.rs#L225

Added line #L225 was not covered by tests
(ADD_IDENTITY, |s| match (&s.args[..], &s.body[..]) {
([identity], []) => Ok(identity.clone()),
_ => Err(Error::Internal {
Expand Down Expand Up @@ -255,6 +255,7 @@ pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {
}
}),
(None, |_| Ok(())),
(None, |_| Ok(())),

Check warning on line 258 in age-plugin/src/identity.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/identity.rs#L258

Added line #L258 was not covered by tests
)?;

// Now that we have the full list of identities, parse them as Bech32 and add them
Expand Down
6 changes: 5 additions & 1 deletion age-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
//! };
//! use clap::Parser;
//!
//! use std::collections::HashMap;
//! use std::collections::{HashMap, HashSet};
//! use std::io;
//!
//! struct Handler;
Expand Down Expand Up @@ -117,6 +117,10 @@
//! todo!()
//! }
//!
//! fn labels(&mut self) -> HashSet<String> {
//! todo!()
//! }
//!
//! fn wrap_file_keys(
//! &mut self,
//! file_keys: Vec<FileKey>,
Expand Down
100 changes: 84 additions & 16 deletions age-plugin/src/recipient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use age_core::{
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::FromBase32;

use std::collections::HashSet;
use std::convert::Infallible;
use std::io;

Expand All @@ -16,7 +17,9 @@ use crate::{Callbacks, PLUGIN_IDENTITY_PREFIX, PLUGIN_RECIPIENT_PREFIX};
const ADD_RECIPIENT: &str = "add-recipient";
const ADD_IDENTITY: &str = "add-identity";
const WRAP_FILE_KEY: &str = "wrap-file-key";
const EXTENSION_LABELS: &str = "extension-labels";
const RECIPIENT_STANZA: &str = "recipient-stanza";
const LABELS: &str = "labels";

/// The interface that age implementations will use to interact with an age plugin.
///
Expand All @@ -39,6 +42,35 @@ pub trait RecipientPluginV1 {
/// Returns an error if the identity is unknown or invalid.
fn add_identity(&mut self, index: usize, plugin_name: &str, bytes: &[u8]) -> Result<(), Error>;

/// Returns labels that constrain how the stanzas produced by [`Self::wrap_file_keys`]
/// may be combined with those from other recipients.
///
/// Encryption will succeed only if every recipient returns the same set of labels.
/// Subsets or partial overlapping sets are not allowed; all sets must be identical.
/// Labels are compared exactly, and are case-sensitive.
///
/// Label sets can be used to ensure a recipient is only encrypted to alongside other
/// recipients with equivalent properties, or to ensure a recipient is always used
/// alone. A recipient with no particular properties to enforce should return an empty
/// label set.
///
/// Labels can have any value, but usually take one of several forms:
/// - *Common public label* - used by multiple recipients to permit their stanzas to
/// be used only together. Examples include:
/// - `postquantum` - indicates that the recipient stanzas being generated are
/// postquantum-secure, and that they can only be combined with other stanzas
/// that are also postquantum-secure.
/// - *Common private label* - used by recipients created by the same private entity
/// to permit their recipient stanzas to be used only together. For example,
/// private recipients used in a corporate environment could all send the same
/// private label in order to prevent compliant age clients from simultaneously
/// wrapping file keys with other recipients.
/// - *Random label* - used by recipients that want to ensure their stanzas are not
/// used with any other recipient stanzas. This can be used to produce a file key
/// that is only encrypted to a single recipient stanza, for example to preserve
/// its authentication properties.
fn labels(&mut self) -> HashSet<String>;

/// Wraps each `file_key` to all recipients and identities previously added via
/// `add_recipient` and `add_identity`.
///
Expand All @@ -65,6 +97,11 @@ impl RecipientPluginV1 for Infallible {
Ok(())
}

fn labels(&mut self) -> HashSet<String> {

Check warning on line 100 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L100

Added line #L100 was not covered by tests
// This is never executed.
HashSet::new()

Check warning on line 102 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L102

Added line #L102 was not covered by tests
}

fn wrap_file_keys(
&mut self,
_: Vec<FileKey>,
Expand Down Expand Up @@ -215,8 +252,8 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
let mut conn = Connection::accept();

// Phase 1: collect recipients, and file keys to be wrapped
let ((recipients, identities), file_keys) = {
let (recipients, identities, file_keys) = conn.unidir_receive(
let ((recipients, identities), file_keys, labels_supported) = {
let (recipients, identities, file_keys, labels_supported) = conn.unidir_receive(

Check warning on line 256 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L255-L256

Added lines #L255 - L256 were not covered by tests
(ADD_RECIPIENT, |s| match (&s.args[..], &s.body[..]) {
([recipient], []) => Ok(recipient.clone()),
_ => Err(Error::Internal {
Expand All @@ -243,6 +280,7 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
})
.map(FileKey::from)
}),
(Some(EXTENSION_LABELS), |_| Ok(())),

Check warning on line 283 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L283

Added line #L283 was not covered by tests
)?;
(
match (recipients, identities) {
Expand All @@ -263,6 +301,13 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
}]),
r => r,
},
match &labels_supported.unwrap() {
Ok(v) if v.is_empty() => Ok(false),
Ok(v) if v.len() == 1 => Ok(true),
_ => Err(vec![Error::Internal {
message: format!("Received more than one {} command", EXTENSION_LABELS),

Check warning on line 308 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L304-L308

Added lines #L304 - L308 were not covered by tests
}]),
},
)
};

Expand Down Expand Up @@ -327,23 +372,46 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
|index, plugin_name, bytes| plugin.add_identity(index, plugin_name, &bytes),
);

let required_labels = plugin.labels();

Check warning on line 375 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L375

Added line #L375 was not covered by tests

let labels = match (labels_supported, required_labels.is_empty()) {
(Ok(true), _) | (Ok(false), true) => Ok(required_labels),
(Ok(false), false) => Err(vec![Error::Internal {
message: "Plugin requires labels but client does not support them".into(),

Check warning on line 380 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L377-L380

Added lines #L377 - L380 were not covered by tests
}]),
(Err(errors), true) => Err(errors),
(Err(mut errors), false) => {
errors.push(Error::Internal {
message: "Plugin requires labels but client does not support them".into(),

Check warning on line 385 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L382-L385

Added lines #L382 - L385 were not covered by tests
});
Err(errors)

Check warning on line 387 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L387

Added line #L387 was not covered by tests
}
};

// Phase 2: wrap the file keys or return errors
conn.bidir_send(|mut phase| {
let (expected_stanzas, file_keys) = match (recipients, identities, file_keys) {
(Ok(recipients), Ok(identities), Ok(file_keys)) => (recipients + identities, file_keys),
(recipients, identities, file_keys) => {
for error in recipients
.err()
.into_iter()
.chain(identities.err())
.chain(file_keys.err())
.flatten()
{
error.send(&mut phase)?;
let (expected_stanzas, file_keys, labels) =
match (recipients, identities, file_keys, labels) {
(Ok(recipients), Ok(identities), Ok(file_keys), Ok(labels)) => {
(recipients + identities, file_keys, labels)

Check warning on line 396 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L393-L396

Added lines #L393 - L396 were not covered by tests
}
return Ok(());
}
};
(recipients, identities, file_keys, labels) => {
for error in recipients
.err()
.into_iter()
.chain(identities.err())
.chain(file_keys.err())
.chain(labels.err())
.flatten()

Check warning on line 405 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L398-L405

Added lines #L398 - L405 were not covered by tests
{
error.send(&mut phase)?;

Check warning on line 407 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L407

Added line #L407 was not covered by tests
}
return Ok(());

Check warning on line 409 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L409

Added line #L409 was not covered by tests
}
};

let labels = labels.iter().map(|s| s.as_str()).collect::<Vec<_>>();
phase.send(LABELS, &labels, &[])?.unwrap();

Check warning on line 414 in age-plugin/src/recipient.rs

View check run for this annotation

Codecov / codecov/patch

age-plugin/src/recipient.rs#L413-L414

Added lines #L413 - L414 were not covered by tests

match plugin.wrap_file_keys(file_keys, BidirCallbacks(&mut phase))? {
Ok(files) => {
Expand Down

0 comments on commit befd8b3

Please sign in to comment.