Skip to content

Commit 64bf20a

Browse files
authored
Merge pull request #1 from thesis/better-expose-library
Better expose library
2 parents fb4da2a + e80dd9f commit 64bf20a

File tree

13 files changed

+195
-87
lines changed

13 files changed

+195
-87
lines changed

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Release
22

33
on:
44
push:
5-
tags: ["v*"]
5+
tags: ["age-plugin-argon2-v*"]
66

77
permissions:
88
contents: write
@@ -19,10 +19,10 @@ jobs:
1919
- uses: Swatinem/rust-cache@v2
2020

2121
- name: Dry-run publish
22-
run: cargo publish --dry-run
22+
run: cargo publish --dry-run -p age-plugin-argon2
2323

2424
- name: Publish to crates.io
25-
run: cargo publish
25+
run: cargo publish -p age-plugin-argon2
2626
env:
2727
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
2828

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,3 @@
1-
[package]
2-
name = "age-plugin-argon2"
3-
version = "0.2.0"
4-
edition = "2021"
5-
license = "MIT OR Apache-2.0"
6-
description = "Argon2id recipient/identity plugin for the age encryption format"
7-
repository = "https://github.com/thesis/age-plugin-argon2"
8-
categories = ["cryptography"]
9-
keywords = ["age", "argon2", "kdf", "encryption"]
10-
11-
[dependencies]
12-
age-core = "0.11"
13-
age = "0.11"
14-
argon2 = { version = "0.5", features = ["std"] }
15-
chacha20poly1305 = "0.10"
16-
rand = { version = "0.8", features = ["std_rng"] }
17-
base64 = "0.22"
18-
uuid = { version = "1", features = ["v4"] }
19-
zeroize = { version = "1.7", features = ["derive"] }
20-
hmac = "0.12"
21-
sha2 = "0.10"
22-
hkdf = "0.12"
23-
secrecy = "0.10"
24-
serde = { version = "1.0", features = ["derive"] }
25-
thiserror = "1.0"
26-
27-
[dev-dependencies]
28-
serde_json = "1.0"
29-
tempfile = "3"
1+
[workspace]
2+
members = ["age-plugin-argon2", "age-plugin-argon2-cli"]
3+
resolver = "2"

age-plugin-argon2-cli/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "age-plugin-argon2-cli"
3+
version = "0.1.0"
4+
edition = "2021"
5+
license = "MIT OR Apache-2.0"
6+
publish = false
7+
8+
[[bin]]
9+
name = "age-plugin-argon2"
10+
path = "src/main.rs"
11+
12+
[dependencies]
13+
age-plugin-argon2 = { path = "../age-plugin-argon2" }

age-plugin-argon2-cli/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fn main() {
2+
eprintln!("age-plugin-argon2: plugin binary not yet implemented");
3+
std::process::exit(1);
4+
}

age-plugin-argon2/Cargo.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "age-plugin-argon2"
3+
version = "0.2.0"
4+
edition = "2021"
5+
license = "MIT OR Apache-2.0"
6+
description = "Argon2id recipient/identity plugin for the age encryption format"
7+
repository = "https://github.com/thesis/age-plugin-argon2"
8+
readme = "../README.md"
9+
categories = ["cryptography", "authentication"]
10+
keywords = ["age", "argon2", "encryption", "password", "plugin"]
11+
12+
[dependencies]
13+
age-core = "0.11"
14+
age = "0.11"
15+
argon2 = { version = "0.5", features = ["std"] }
16+
chacha20poly1305 = "0.10"
17+
rand = { version = "0.8", features = ["std_rng"] }
18+
base64 = "0.22"
19+
uuid = { version = "1", features = ["v4"] }
20+
zeroize = { version = "1.7", features = ["derive"] }
21+
hmac = "0.12"
22+
sha2 = "0.10"
23+
hkdf = "0.12"
24+
secrecy = "0.10"
25+
serde = { version = "1.0", features = ["derive"] }
26+
thiserror = "1.0"
27+
28+
[dev-dependencies]
29+
serde_json = "1.0"
30+
tempfile = "3"

src/cached.rs renamed to age-plugin-argon2/src/cached.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub struct CachedIdentity {
3737
}
3838

3939
impl CachedIdentity {
40+
/// Create a new cached identity from previously captured key material.
4041
pub fn new(material: &CachedMaterial) -> Self {
4142
Self {
4243
file_key: material.file_key,
@@ -78,6 +79,7 @@ pub struct CachedRecipient {
7879
}
7980

8081
impl CachedRecipient {
82+
/// Create a new cached recipient from previously captured key material.
8183
pub fn new(material: &CachedMaterial) -> Self {
8284
Self {
8385
wrapping_key: material.wrapping_key,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,16 @@ pub fn encrypt_with_file_key(
124124
Ok(output)
125125
}
126126

127+
/// Error returned by [`encrypt_with_file_key`].
127128
#[derive(Debug, thiserror::Error)]
128129
pub enum EncryptWithFileKeyError {
130+
/// The recipient failed to wrap the file key.
129131
#[error("failed to wrap file key: {0}")]
130132
Wrap(String),
133+
/// An I/O error occurred while building the output.
131134
#[error("I/O error: {0}")]
132135
Io(String),
136+
/// A cryptographic operation failed.
133137
#[error("cryptographic error: {0}")]
134138
Crypto(String),
135139
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub struct Argon2idIdentity {
2323
}
2424

2525
impl Argon2idIdentity {
26+
/// Create a new identity from a passphrase.
2627
pub fn new(passphrase: &[u8]) -> Self {
2728
Self {
2829
passphrase: passphrase.to_vec(),

age-plugin-argon2/src/lib.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#![warn(missing_docs)]
2+
3+
//! Argon2id recipient/identity plugin for the age encryption format.
4+
//!
5+
//! This crate provides password-based encryption for [age] files using Argon2id
6+
//! key derivation instead of scrypt. It also provides cached variants that skip
7+
//! the KDF entirely for session-based workflows.
8+
//!
9+
//! [age]: https://age-encryption.org
10+
//!
11+
//! # Quick start
12+
//!
13+
//! Full KDF encrypt → decrypt roundtrip:
14+
//!
15+
//! ```rust
16+
//! use age_plugin_argon2::{Argon2idRecipient, Argon2idIdentity, Argon2Params};
17+
//! use age::{Recipient, Identity};
18+
//! use age_core::format::FileKey;
19+
//! use secrecy::ExposeSecret;
20+
//!
21+
//! let passphrase = b"hunter2";
22+
//! let params = Argon2Params::new(256, 1, 1).unwrap(); // use stronger params in production
23+
//!
24+
//! // Encrypt
25+
//! let recipient = Argon2idRecipient::new(passphrase, params);
26+
//! let file_key = FileKey::new(Box::new([0u8; 16]));
27+
//! let (stanzas, _labels) = recipient.wrap_file_key(&file_key).unwrap();
28+
//!
29+
//! // Decrypt
30+
//! let identity = Argon2idIdentity::new(passphrase);
31+
//! let recovered = identity.unwrap_stanza(&stanzas[0]).unwrap().unwrap();
32+
//! assert_eq!(recovered.expose_secret(), file_key.expose_secret());
33+
//! ```
34+
//!
35+
//! # Cached / session mode
36+
//!
37+
//! After an initial KDF decryption, captured material can be reused to avoid
38+
//! running Argon2id on every subsequent encrypt/decrypt:
39+
//!
40+
//! ```rust
41+
//! use age_plugin_argon2::{Argon2idRecipient, Argon2idIdentity, CachedRecipient, CachedIdentity, Argon2Params};
42+
//! use age::{Recipient, Identity};
43+
//! use age_core::format::FileKey;
44+
//! use secrecy::ExposeSecret;
45+
//!
46+
//! let passphrase = b"hunter2";
47+
//! let params = Argon2Params::new(256, 1, 1).unwrap();
48+
//!
49+
//! // Initial full-KDF encrypt + decrypt to capture session material
50+
//! let (stanzas, _) = Argon2idRecipient::new(passphrase, params)
51+
//! .wrap_file_key(&FileKey::new(Box::new([0u8; 16])))
52+
//! .unwrap();
53+
//! let identity = Argon2idIdentity::new(passphrase);
54+
//! identity.unwrap_stanza(&stanzas[0]).unwrap().unwrap();
55+
//! let material = identity.captured_material().unwrap();
56+
//!
57+
//! // Session re-encrypt without running Argon2id
58+
//! let session_key = FileKey::new(Box::new(material.file_key));
59+
//! let (session_stanzas, _) = CachedRecipient::new(&material)
60+
//! .wrap_file_key(&session_key)
61+
//! .unwrap();
62+
//!
63+
//! // Session decrypt — also skips KDF
64+
//! let recovered = CachedIdentity::new(&material)
65+
//! .unwrap_stanza(&session_stanzas[0])
66+
//! .unwrap()
67+
//! .unwrap();
68+
//! assert_eq!(recovered.expose_secret(), &[0u8; 16]);
69+
//! ```
70+
//!
71+
//! # Security model
72+
//!
73+
//! Two operational modes with different security/performance trade-offs:
74+
//!
75+
//! ## Full KDF (`Argon2idRecipient` / `Argon2idIdentity`)
76+
//!
77+
//! Used at session boundaries (init, unlock). Every encrypt/decrypt runs the
78+
//! full Argon2id KDF to derive a wrapping key from the passphrase + random salt.
79+
//! The wrapping key protects the age FileKey via ChaCha20-Poly1305 AEAD.
80+
//!
81+
//! - **Encrypt**: random salt → Argon2id → wrapping key → AEAD-wrap FileKey
82+
//! - **Decrypt**: parse salt from stanza → Argon2id → wrapping key → AEAD-unwrap FileKey
83+
//! - **Key capture**: on successful decrypt, `Argon2idIdentity` captures the
84+
//! FileKey + wrapping key + salt as [`CachedMaterial`] for session caching
85+
//!
86+
//! ## Cached / zero-KDF (`CachedRecipient` / `CachedIdentity`)
87+
//!
88+
//! Used during an active session after the initial unlock. The passphrase is
89+
//! never stored — only opaque key material (64 bytes) lives in the OS keychain.
90+
//!
91+
//! - **`CachedRecipient`** (writes): reuses the captured wrapping key + salt to
92+
//! AEAD-wrap the FileKey without running Argon2id. Produces stanzas
93+
//! indistinguishable from full-KDF output.
94+
//! - **`CachedIdentity`** (reads): returns the cached FileKey directly.
95+
//! Stanza body verification is intentionally skipped because the age STREAM
96+
//! layer provides per-chunk Poly1305 authentication — a wrong FileKey will
97+
//! fail at payload decryption, not silently produce garbage.
98+
//!
99+
//! ## Stanza format
100+
//!
101+
//! ```text
102+
//! -> thesis.co/argon2 <base64-salt> <m_cost> <t_cost> <p_cost>
103+
//! <AEAD-wrapped FileKey>
104+
//! ```
105+
//!
106+
//! The namespaced tag (`thesis.co/argon2`) avoids collisions with any future
107+
//! upstream age scrypt/argon2 recipient type.
108+
109+
/// Cached / zero-KDF recipient and identity for session use.
110+
pub mod cached;
111+
/// Low-level age-format encryption with a caller-supplied FileKey.
112+
pub mod encrypt;
113+
/// Full-KDF identity for passphrase-based decryption.
114+
pub mod identity;
115+
/// Validated Argon2id parameters.
116+
pub mod params;
117+
/// Full-KDF recipient for passphrase-based encryption.
118+
pub mod recipient;
119+
120+
pub use cached::{CachedIdentity, CachedMaterial, CachedRecipient};
121+
pub use encrypt::{encrypt_with_file_key, EncryptWithFileKeyError};
122+
pub use identity::Argon2idIdentity;
123+
pub use params::{Argon2Params, InvalidParams};
124+
pub use recipient::Argon2idRecipient;

0 commit comments

Comments
 (0)