2830 lines
90 KiB
Markdown
2830 lines
90 KiB
Markdown
|
|
# notsecrets Age-Native Redesign Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** Redesign notsecrets as a self-contained age encryption/decryption library in pure Rust with multiple recipients, passphrases, passphrase-protected identity files, and SSH keys.
|
|||
|
|
|
|||
|
|
**Architecture:** Hexagonal architecture with Identity and Recipient as domain ports. Concrete implementations (X25519, Scrypt, SshEd25519, SshRsa, Encrypted) live in infra adapters. Encryptor/Decryptor orchestrate at the domain layer. Sources (Bitwarden, file, prompt) are identity resolvers at the infra boundary.
|
|||
|
|
|
|||
|
|
**Tech Stack:** x25519-dalek, ed25519-dalek, rsa, sha2, hkdf, chacha20poly1305, scrypt, bech32, base64, hmac, rand
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: Scaffold — Cargo.toml, error.rs, domain traits, stub lib.rs
|
|||
|
|
|
|||
|
|
- [ ] Update `crates/notsecrets/Cargo.toml` — add new deps, remove `which`
|
|||
|
|
- [ ] Create `crates/notsecrets/src/error.rs`
|
|||
|
|
- [ ] Create `crates/notsecrets/src/identities/mod.rs` — domain types + `Identity` trait
|
|||
|
|
- [ ] Create `crates/notsecrets/src/recipients/mod.rs` — `Recipient` trait
|
|||
|
|
- [ ] Rewrite `crates/notsecrets/src/lib.rs` — stub public API, remove old exports
|
|||
|
|
|
|||
|
|
### 1.1 Update `crates/notsecrets/Cargo.toml`
|
|||
|
|
|
|||
|
|
```toml
|
|||
|
|
[package]
|
|||
|
|
name = "notsecrets"
|
|||
|
|
version = "0.1.0"
|
|||
|
|
edition = "2024"
|
|||
|
|
license.workspace = true
|
|||
|
|
|
|||
|
|
[dependencies]
|
|||
|
|
notcore = { workspace = true }
|
|||
|
|
anyhow = { workspace = true }
|
|||
|
|
thiserror = { workspace = true }
|
|||
|
|
rpassword = { workspace = true }
|
|||
|
|
dirs = { workspace = true }
|
|||
|
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
|||
|
|
ed25519-dalek = "2"
|
|||
|
|
rsa = { version = "0.9", features = ["sha2"] }
|
|||
|
|
sha2 = "1"
|
|||
|
|
hkdf = "0.12"
|
|||
|
|
chacha20poly1305 = "0.10"
|
|||
|
|
scrypt = "0.11"
|
|||
|
|
bech32 = "0.11"
|
|||
|
|
base64 = "0.22"
|
|||
|
|
hmac = "0.12"
|
|||
|
|
rand = "0.8"
|
|||
|
|
|
|||
|
|
[dev-dependencies]
|
|||
|
|
tempfile = { workspace = true }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Also update workspace `Cargo.toml` — remove `which` from `[workspace.dependencies]` only after verifying no other crate uses it:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo metadata --no-deps --format-version 1 | grep -o '"which"' | wc -l
|
|||
|
|
# if 0 remaining consumers after notsecrets drop, remove from workspace
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.2 `crates/notsecrets/src/error.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[derive(Debug, thiserror::Error)]
|
|||
|
|
pub enum AgeError {
|
|||
|
|
#[error("no identity could decrypt any recipient stanza")]
|
|||
|
|
NoMatch,
|
|||
|
|
#[error("header MAC verification failed")]
|
|||
|
|
MacMismatch,
|
|||
|
|
#[error("malformed age file: {0}")]
|
|||
|
|
ParseError(String),
|
|||
|
|
#[error("crypto error: {0}")]
|
|||
|
|
CryptoError(String),
|
|||
|
|
#[error("unsupported key type: {0}")]
|
|||
|
|
UnsupportedKeyType(String),
|
|||
|
|
#[error("identity source failed ({name}): {source}")]
|
|||
|
|
SourceError { name: String, source: anyhow::Error },
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 `crates/notsecrets/src/identities/mod.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
|
|||
|
|
/// The symmetric file encryption key — 16 random bytes.
|
|||
|
|
#[derive(Clone, zeroize::Zeroize)]
|
|||
|
|
pub struct FileKey([u8; 16]);
|
|||
|
|
|
|||
|
|
impl FileKey {
|
|||
|
|
pub fn new(bytes: [u8; 16]) -> Self {
|
|||
|
|
Self(bytes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn as_bytes(&self) -> &[u8; 16] {
|
|||
|
|
&self.0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn generate() -> Self {
|
|||
|
|
use rand::RngCore;
|
|||
|
|
let mut bytes = [0u8; 16];
|
|||
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|||
|
|
Self(bytes)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl TryFrom<&[u8]> for FileKey {
|
|||
|
|
type Error = AgeError;
|
|||
|
|
fn try_from(b: &[u8]) -> Result<Self, AgeError> {
|
|||
|
|
b.try_into()
|
|||
|
|
.map(Self)
|
|||
|
|
.map_err(|_| AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len())))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A single recipient stanza in the age header.
|
|||
|
|
#[derive(Debug, Clone)]
|
|||
|
|
pub struct Stanza {
|
|||
|
|
pub tag: String,
|
|||
|
|
pub args: Vec<String>,
|
|||
|
|
pub body: Vec<u8>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The full age header (all stanzas + MAC).
|
|||
|
|
#[derive(Debug, Clone)]
|
|||
|
|
pub struct Header {
|
|||
|
|
pub recipients: Vec<Stanza>,
|
|||
|
|
pub mac: Vec<u8>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Domain port: an identity that can attempt to unwrap a recipient stanza.
|
|||
|
|
///
|
|||
|
|
/// Returns `None` if the stanza tag/args do not match this identity type.
|
|||
|
|
/// Returns `Some(Err(...))` if the stanza matches but decryption fails.
|
|||
|
|
/// Returns `Some(Ok(file_key))` on success.
|
|||
|
|
pub trait Identity {
|
|||
|
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Note: `zeroize` must be added to `[dependencies]` in `crates/notsecrets/Cargo.toml`:
|
|||
|
|
|
|||
|
|
```toml
|
|||
|
|
zeroize = "1"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add this line alongside the others in step 1.1.
|
|||
|
|
|
|||
|
|
### 1.4 `crates/notsecrets/src/recipients/mod.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Stanza};
|
|||
|
|
|
|||
|
|
/// Domain port: a recipient that can wrap a file key into a stanza.
|
|||
|
|
pub trait Recipient {
|
|||
|
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.5 `crates/notsecrets/src/lib.rs` (stub)
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod error;
|
|||
|
|
pub mod identities;
|
|||
|
|
pub mod recipients;
|
|||
|
|
pub mod sources;
|
|||
|
|
|
|||
|
|
pub use error::AgeError;
|
|||
|
|
pub use identities::{FileKey, Header, Identity, Stanza};
|
|||
|
|
pub use recipients::Recipient;
|
|||
|
|
pub use sources::{BitwardenSource, FileSource, IdentitySource, PromptSource};
|
|||
|
|
|
|||
|
|
// Encryptor and Decryptor will be added in Tasks 8 and 9.
|
|||
|
|
// resolve_identities will be added in Task 11.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Keep the existing `sources/` files compiling by leaving `AgeKeySource` in place temporarily — it will be replaced in Task 10. For now add a `#[allow(dead_code)]` allow on the old trait and old `resolve_age_key`/`install_age_key`/`decrypt_sops` functions so the crate compiles while tasks proceed incrementally. Annotate each deprecated item:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[deprecated(note = "replaced by resolve_identities in Task 11")]
|
|||
|
|
pub use _legacy::{
|
|||
|
|
install_age_key, install_age_key_at, resolve_age_key, AgeKeySource, decrypt_sops, sops_args,
|
|||
|
|
};
|
|||
|
|
mod _legacy;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Move the old body of `lib.rs` (everything except `pub mod sources`) into `crates/notsecrets/src/_legacy.rs` verbatim so existing callers in `notstrap` keep compiling until Task 12.
|
|||
|
|
|
|||
|
|
### 1.6 Verify
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo check -p notsecrets
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: compiles, zero errors.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings (the `#[deprecated]` re-exports suppress dead_code warnings; `#[allow(deprecated)]` needed in `notstrap` temporarily — add it in Task 12).
|
|||
|
|
|
|||
|
|
### 1.7 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/ Cargo.toml
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): scaffold age-native domain types and trait ports"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: format.rs — age wire format parser/serializer
|
|||
|
|
|
|||
|
|
**No crypto. Pure byte manipulation.**
|
|||
|
|
|
|||
|
|
- [ ] Create `crates/notsecrets/src/format.rs`
|
|||
|
|
- [ ] Write tests first, then implement
|
|||
|
|
|
|||
|
|
### 2.1 Test (write first)
|
|||
|
|
|
|||
|
|
File: `crates/notsecrets/src/format.rs` (tests section)
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
|
|||
|
|
fn minimal_age_file() -> Vec<u8> {
|
|||
|
|
// Manually constructed minimal age file with one X25519 stanza
|
|||
|
|
// and a 32-byte payload (nonce=16 bytes of 0x01, ciphertext=16 bytes of 0x02)
|
|||
|
|
let mut out = Vec::new();
|
|||
|
|
out.extend_from_slice(b"age-encryption.org/v1\n");
|
|||
|
|
out.extend_from_slice(b"-> X25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n");
|
|||
|
|
// empty stanza body (no wrapped key bytes for parse test)
|
|||
|
|
out.extend_from_slice(b"\n");
|
|||
|
|
// MAC line: "--- " + base64(32 zero bytes)
|
|||
|
|
out.extend_from_slice(b"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n");
|
|||
|
|
// payload: 16-byte nonce + 16-byte ciphertext placeholder
|
|||
|
|
out.extend_from_slice(&[0x01u8; 16]);
|
|||
|
|
out.extend_from_slice(&[0x02u8; 16]);
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn parse_minimal_header_roundtrip() {
|
|||
|
|
let input = minimal_age_file();
|
|||
|
|
let (header, payload_offset) = parse_header(&input).expect("parse should succeed");
|
|||
|
|
assert_eq!(header.recipients.len(), 1);
|
|||
|
|
assert_eq!(header.recipients[0].tag, "X25519");
|
|||
|
|
assert_eq!(header.recipients[0].args.len(), 1);
|
|||
|
|
assert!(header.recipients[0].body.is_empty());
|
|||
|
|
// payload offset should point past the "--- MAC\n" line
|
|||
|
|
assert_eq!(&input[payload_offset..], &[0x01u8; 16][..].iter().chain([0x02u8; 16].iter()).copied().collect::<Vec<_>>()[..]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn serialize_header_roundtrip() {
|
|||
|
|
use crate::identities::{Header, Stanza};
|
|||
|
|
let stanza = Stanza {
|
|||
|
|
tag: "X25519".to_string(),
|
|||
|
|
args: vec!["dGVzdA==".to_string()],
|
|||
|
|
body: vec![0xde, 0xad, 0xbe, 0xef],
|
|||
|
|
};
|
|||
|
|
let header = Header {
|
|||
|
|
recipients: vec![stanza],
|
|||
|
|
mac: vec![0u8; 32],
|
|||
|
|
};
|
|||
|
|
let serialized = serialize_header(&header);
|
|||
|
|
let (parsed, _) = parse_header(&[serialized.clone(), vec![0u8; 32]].concat())
|
|||
|
|
.expect("round-trip parse should succeed");
|
|||
|
|
assert_eq!(parsed.recipients.len(), 1);
|
|||
|
|
assert_eq!(parsed.recipients[0].tag, "X25519");
|
|||
|
|
assert_eq!(parsed.recipients[0].body, vec![0xde, 0xad, 0xbe, 0xef]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn parse_missing_version_line_returns_error() {
|
|||
|
|
let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n";
|
|||
|
|
let result = parse_header(input);
|
|||
|
|
assert!(result.is_err(), "expected ParseError for missing version line");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn parse_truncated_before_footer_returns_error() {
|
|||
|
|
let input = b"age-encryption.org/v1\n-> X25519 arg\n";
|
|||
|
|
let result = parse_header(input);
|
|||
|
|
assert!(result.is_err(), "expected ParseError for truncated header");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn parse_empty_input_returns_error() {
|
|||
|
|
let result = parse_header(b"");
|
|||
|
|
assert!(result.is_err());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn stanza_body_split_across_multiple_64char_lines() {
|
|||
|
|
// Body longer than 64 chars must be split into 64-char lines per spec.
|
|||
|
|
// 48 bytes -> base64 = 64 chars (one line, no split needed)
|
|||
|
|
// 49 bytes -> base64 = 68 chars (must split: 64 + 4)
|
|||
|
|
use crate::identities::{Header, Stanza};
|
|||
|
|
let body = vec![0xabu8; 49];
|
|||
|
|
let stanza = Stanza {
|
|||
|
|
tag: "scrypt".to_string(),
|
|||
|
|
args: vec!["salt".to_string(), "18".to_string()],
|
|||
|
|
body: body.clone(),
|
|||
|
|
};
|
|||
|
|
let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] };
|
|||
|
|
let serialized = serialize_header(&header);
|
|||
|
|
let (parsed, _) = parse_header(&[serialized, vec![0u8; 32]].concat()).unwrap();
|
|||
|
|
assert_eq!(parsed.recipients[0].body, body);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn header_bytes_up_to_footer_excludes_mac() {
|
|||
|
|
let input = minimal_age_file();
|
|||
|
|
let bytes = header_bytes_up_to_footer(&input).expect("should find footer");
|
|||
|
|
// Must end with "--- " (not include the MAC value)
|
|||
|
|
assert!(bytes.ends_with(b"--- "), "header_bytes must end at '--- '");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Run the test to confirm it fails before implementation:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- format --nocapture 2>&1 | head -30
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: compile error — `parse_header`, `serialize_header`, `header_bytes_up_to_footer` do not exist yet.
|
|||
|
|
|
|||
|
|
### 2.2 Implementation `crates/notsecrets/src/format.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{Header, Stanza};
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
|
|||
|
|
const VERSION_LINE: &[u8] = b"age-encryption.org/v1\n";
|
|||
|
|
const FOOTER_PREFIX: &[u8] = b"--- ";
|
|||
|
|
|
|||
|
|
/// Parse an age binary file's header.
|
|||
|
|
///
|
|||
|
|
/// Returns `(Header, payload_offset)` where `payload_offset` is the byte
|
|||
|
|
/// index into `input` where the payload (nonce + ciphertext) begins.
|
|||
|
|
pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> {
|
|||
|
|
if !input.starts_with(VERSION_LINE) {
|
|||
|
|
return Err(AgeError::ParseError(
|
|||
|
|
"missing age version line".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let mut pos = VERSION_LINE.len();
|
|||
|
|
let mut recipients: Vec<Stanza> = Vec::new();
|
|||
|
|
|
|||
|
|
loop {
|
|||
|
|
if pos >= input.len() {
|
|||
|
|
return Err(AgeError::ParseError("unexpected end of header".to_string()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for footer line: "--- <base64-mac>\n"
|
|||
|
|
if input[pos..].starts_with(FOOTER_PREFIX) {
|
|||
|
|
let line_end = input[pos..]
|
|||
|
|
.iter()
|
|||
|
|
.position(|&b| b == b'\n')
|
|||
|
|
.ok_or_else(|| AgeError::ParseError("footer line not terminated".to_string()))?;
|
|||
|
|
let mac_b64 = &input[pos + FOOTER_PREFIX.len()..pos + line_end];
|
|||
|
|
let mac = STANDARD_NO_PAD
|
|||
|
|
.decode(mac_b64)
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("footer MAC base64: {e}")))?;
|
|||
|
|
let payload_offset = pos + line_end + 1; // skip trailing '\n'
|
|||
|
|
return Ok((Header { recipients, mac }, payload_offset));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Must be a stanza line: "-> <tag> [args...]\n"
|
|||
|
|
if !input[pos..].starts_with(b"-> ") {
|
|||
|
|
return Err(AgeError::ParseError(format!(
|
|||
|
|
"expected stanza or footer at byte {pos}"
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let line_end = input[pos..]
|
|||
|
|
.iter()
|
|||
|
|
.position(|&b| b == b'\n')
|
|||
|
|
.ok_or_else(|| AgeError::ParseError("stanza header line not terminated".to_string()))?;
|
|||
|
|
|
|||
|
|
let header_line = std::str::from_utf8(&input[pos + 3..pos + line_end])
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("stanza line UTF-8: {e}")))?;
|
|||
|
|
|
|||
|
|
let mut parts = header_line.split(' ');
|
|||
|
|
let tag = parts
|
|||
|
|
.next()
|
|||
|
|
.ok_or_else(|| AgeError::ParseError("stanza missing tag".to_string()))?
|
|||
|
|
.to_string();
|
|||
|
|
let args: Vec<String> = parts.map(str::to_string).collect();
|
|||
|
|
|
|||
|
|
pos += line_end + 1;
|
|||
|
|
|
|||
|
|
// Read stanza body: base64 lines until an empty line
|
|||
|
|
let mut body_b64 = String::new();
|
|||
|
|
loop {
|
|||
|
|
if pos >= input.len() {
|
|||
|
|
return Err(AgeError::ParseError(
|
|||
|
|
"unexpected end during stanza body".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
let line_end = input[pos..]
|
|||
|
|
.iter()
|
|||
|
|
.position(|&b| b == b'\n')
|
|||
|
|
.ok_or_else(|| {
|
|||
|
|
AgeError::ParseError("stanza body line not terminated".to_string())
|
|||
|
|
})?;
|
|||
|
|
let line = &input[pos..pos + line_end];
|
|||
|
|
pos += line_end + 1;
|
|||
|
|
if line.is_empty() {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
body_b64.push_str(
|
|||
|
|
std::str::from_utf8(line)
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("body UTF-8: {e}")))?,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let body = if body_b64.is_empty() {
|
|||
|
|
Vec::new()
|
|||
|
|
} else {
|
|||
|
|
STANDARD_NO_PAD
|
|||
|
|
.decode(&body_b64)
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("stanza body base64: {e}")))?
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
recipients.push(Stanza { tag, args, body });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Serialize a `Header` to bytes.
|
|||
|
|
///
|
|||
|
|
/// Writes version line, all stanzas (with body split into 64-char base64 lines),
|
|||
|
|
/// and the `--- <base64-mac>` footer. Does NOT write the payload.
|
|||
|
|
pub fn serialize_header(header: &Header) -> Vec<u8> {
|
|||
|
|
let mut out = Vec::new();
|
|||
|
|
out.extend_from_slice(VERSION_LINE);
|
|||
|
|
|
|||
|
|
for stanza in &header.recipients {
|
|||
|
|
// "-> tag arg1 arg2\n"
|
|||
|
|
out.extend_from_slice(b"-> ");
|
|||
|
|
out.extend_from_slice(stanza.tag.as_bytes());
|
|||
|
|
for arg in &stanza.args {
|
|||
|
|
out.push(b' ');
|
|||
|
|
out.extend_from_slice(arg.as_bytes());
|
|||
|
|
}
|
|||
|
|
out.push(b'\n');
|
|||
|
|
|
|||
|
|
// Body: encode as base64, split into 64-char lines, trailing empty line
|
|||
|
|
if stanza.body.is_empty() {
|
|||
|
|
out.push(b'\n');
|
|||
|
|
} else {
|
|||
|
|
let encoded = STANDARD_NO_PAD.encode(&stanza.body);
|
|||
|
|
for chunk in encoded.as_bytes().chunks(64) {
|
|||
|
|
out.extend_from_slice(chunk);
|
|||
|
|
out.push(b'\n');
|
|||
|
|
}
|
|||
|
|
// If the last chunk was exactly 64 chars, we still need the empty terminator line.
|
|||
|
|
// If the last chunk was < 64 chars, we also need the empty terminator line.
|
|||
|
|
out.push(b'\n');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Footer
|
|||
|
|
out.extend_from_slice(FOOTER_PREFIX);
|
|||
|
|
out.extend_from_slice(STANDARD_NO_PAD.encode(&header.mac).as_bytes());
|
|||
|
|
out.push(b'\n');
|
|||
|
|
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Return the header bytes up to and including `"--- "` (not including the MAC value).
|
|||
|
|
///
|
|||
|
|
/// Used for MAC computation: HMAC-SHA256 covers all bytes from the start of the
|
|||
|
|
/// file up to and including the `"--- "` separator.
|
|||
|
|
pub fn header_bytes_up_to_footer(input: &[u8]) -> Result<Vec<u8>, AgeError> {
|
|||
|
|
let footer_pos = input
|
|||
|
|
.windows(FOOTER_PREFIX.len())
|
|||
|
|
.position(|w| w == FOOTER_PREFIX)
|
|||
|
|
.ok_or_else(|| AgeError::ParseError("footer not found".to_string()))?;
|
|||
|
|
Ok(input[..footer_pos + FOOTER_PREFIX.len()].to_vec())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
// ... (tests from 2.1 above)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add `pub mod format;` to `lib.rs`.
|
|||
|
|
|
|||
|
|
### 2.3 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- format --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected output (all pass):
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test format::tests::parse_empty_input_returns_error ... ok
|
|||
|
|
test format::tests::parse_missing_version_line_returns_error ... ok
|
|||
|
|
test format::tests::parse_truncated_before_footer_returns_error ... ok
|
|||
|
|
test format::tests::parse_minimal_header_roundtrip ... ok
|
|||
|
|
test format::tests::serialize_header_roundtrip ... ok
|
|||
|
|
test format::tests::stanza_body_split_across_multiple_64char_lines ... ok
|
|||
|
|
test format::tests::header_bytes_up_to_footer_excludes_mac ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 2.4 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/format.rs crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): age wire format parser/serializer with unit tests"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: X25519 identity + recipient
|
|||
|
|
|
|||
|
|
- [ ] Create `crates/notsecrets/src/identities/x25519.rs`
|
|||
|
|
- [ ] Create `crates/notsecrets/src/recipients/x25519.rs`
|
|||
|
|
- [ ] Update `identities/mod.rs` and `recipients/mod.rs` to re-export
|
|||
|
|
|
|||
|
|
### 3.1 Test (write first)
|
|||
|
|
|
|||
|
|
Add to `crates/notsecrets/src/identities/x25519.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::identities::FileKey;
|
|||
|
|
use crate::recipients::x25519::X25519Recipient;
|
|||
|
|
|
|||
|
|
/// The canonical test vector: generate a keypair, wrap a file key, unwrap it.
|
|||
|
|
#[test]
|
|||
|
|
fn x25519_wrap_unwrap_roundtrip() {
|
|||
|
|
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
// Generate a static identity key
|
|||
|
|
let secret = StaticSecret::random_from_rng(OsRng);
|
|||
|
|
let public = PublicKey::from(&secret);
|
|||
|
|
|
|||
|
|
// bech32-encode public key as age1... recipient
|
|||
|
|
let recipient = X25519Recipient::from_public_key(public);
|
|||
|
|
// bech32-encode secret key as AGE-SECRET-KEY-1... identity
|
|||
|
|
let identity = X25519Identity::from_static_secret(secret);
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x42u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
|||
|
|
assert_eq!(stanza.tag, "X25519");
|
|||
|
|
assert_eq!(stanza.args.len(), 1); // ephemeral pubkey
|
|||
|
|
|
|||
|
|
let unwrapped = identity
|
|||
|
|
.unwrap_file_key(&stanza)
|
|||
|
|
.expect("identity should match")
|
|||
|
|
.expect("unwrap should succeed");
|
|||
|
|
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn x25519_wrong_identity_returns_none_for_nonmatching_tag() {
|
|||
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
let secret = StaticSecret::random_from_rng(OsRng);
|
|||
|
|
let identity = X25519Identity::from_static_secret(secret);
|
|||
|
|
|
|||
|
|
use crate::identities::Stanza;
|
|||
|
|
let stanza = Stanza {
|
|||
|
|
tag: "scrypt".to_string(),
|
|||
|
|
args: vec!["salt".to_string(), "18".to_string()],
|
|||
|
|
body: vec![0u8; 32],
|
|||
|
|
};
|
|||
|
|
// Different tag — must return None, not an error
|
|||
|
|
assert!(identity.unwrap_file_key(&stanza).is_none());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn x25519_bech32_parse_roundtrip() {
|
|||
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
let secret = StaticSecret::random_from_rng(OsRng);
|
|||
|
|
let public = PublicKey::from(&secret);
|
|||
|
|
|
|||
|
|
let recipient_str = X25519Recipient::from_public_key(public).to_bech32();
|
|||
|
|
assert!(recipient_str.starts_with("age1"), "recipient must start with age1");
|
|||
|
|
|
|||
|
|
let identity_str = X25519Identity::from_static_secret(
|
|||
|
|
StaticSecret::random_from_rng(OsRng)
|
|||
|
|
).to_bech32();
|
|||
|
|
assert!(
|
|||
|
|
identity_str.starts_with("AGE-SECRET-KEY-1"),
|
|||
|
|
"identity must start with AGE-SECRET-KEY-1"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Run to confirm compile failure:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- identities::x25519 --nocapture 2>&1 | head -20
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: compile error — modules don't exist yet.
|
|||
|
|
|
|||
|
|
### 3.2 `crates/notsecrets/src/identities/x25519.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Identity, Stanza};
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use bech32::{Bech32, Hrp};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use hkdf::Hkdf;
|
|||
|
|
use sha2::Sha256;
|
|||
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|||
|
|
|
|||
|
|
const TAG: &str = "X25519";
|
|||
|
|
const HKDF_INFO: &[u8] = b"age-encryption.org/v1/X25519";
|
|||
|
|
const IDENTITY_HRP: &str = "age-secret-key-";
|
|||
|
|
|
|||
|
|
pub struct X25519Identity {
|
|||
|
|
secret: StaticSecret,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl X25519Identity {
|
|||
|
|
pub fn from_static_secret(secret: StaticSecret) -> Self {
|
|||
|
|
Self { secret }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Parse an `AGE-SECRET-KEY-1...` bech32 string.
|
|||
|
|
pub fn from_bech32(s: &str) -> Result<Self, AgeError> {
|
|||
|
|
let s_lower = s.to_lowercase();
|
|||
|
|
let (hrp, data) = bech32::decode(&s_lower)
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("bech32 decode identity: {e}")))?;
|
|||
|
|
if hrp.as_str() != IDENTITY_HRP {
|
|||
|
|
return Err(AgeError::ParseError(format!(
|
|||
|
|
"expected hrp '{IDENTITY_HRP}', got '{}'",
|
|||
|
|
hrp.as_str()
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
let bytes: [u8; 32] = data
|
|||
|
|
.try_into()
|
|||
|
|
.map_err(|_| AgeError::ParseError("identity key must be 32 bytes".to_string()))?;
|
|||
|
|
Ok(Self {
|
|||
|
|
secret: StaticSecret::from(bytes),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Encode as `AGE-SECRET-KEY-1...` bech32 uppercase string.
|
|||
|
|
pub fn to_bech32(&self) -> String {
|
|||
|
|
let hrp = Hrp::parse(IDENTITY_HRP).expect("static hrp is valid");
|
|||
|
|
bech32::encode::<Bech32>(hrp, self.secret.as_bytes())
|
|||
|
|
.expect("bech32 encode cannot fail for valid input")
|
|||
|
|
.to_uppercase()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Identity for X25519Identity {
|
|||
|
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
|||
|
|
if stanza.tag != TAG {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
Some(unwrap(stanza, &self.secret))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn unwrap(stanza: &Stanza, secret: &StaticSecret) -> Result<FileKey, AgeError> {
|
|||
|
|
if stanza.args.len() != 1 {
|
|||
|
|
return Err(AgeError::ParseError(
|
|||
|
|
"X25519 stanza must have exactly 1 arg".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
let ephemeral_pub_bytes = STANDARD_NO_PAD
|
|||
|
|
.decode(&stanza.args[0])
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?;
|
|||
|
|
let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes
|
|||
|
|
.try_into()
|
|||
|
|
.map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?;
|
|||
|
|
let ephemeral_pub = PublicKey::from(ephemeral_pub_bytes);
|
|||
|
|
|
|||
|
|
let shared = secret.diffie_hellman(&ephemeral_pub);
|
|||
|
|
let recipient_pub = PublicKey::from(secret);
|
|||
|
|
|
|||
|
|
let wrap_key = derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), recipient_pub.as_bytes())?;
|
|||
|
|
|
|||
|
|
let wrapped = &stanza.body;
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
|||
|
|
let nonce = Nonce::default(); // [0u8; 12]
|
|||
|
|
let file_key_bytes = cipher
|
|||
|
|
.decrypt(&nonce, wrapped.as_slice())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("X25519 file key decryption failed".to_string()))?;
|
|||
|
|
|
|||
|
|
FileKey::try_from(file_key_bytes.as_slice())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) fn derive_wrap_key(
|
|||
|
|
shared: &[u8],
|
|||
|
|
ephemeral_pub: &[u8],
|
|||
|
|
recipient_pub: &[u8],
|
|||
|
|
) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len());
|
|||
|
|
ikm.extend_from_slice(shared);
|
|||
|
|
ikm.extend_from_slice(ephemeral_pub);
|
|||
|
|
ikm.extend_from_slice(recipient_pub);
|
|||
|
|
|
|||
|
|
let hk = Hkdf::<Sha256>::new(None, &ikm);
|
|||
|
|
let mut wrap_key = [0u8; 32];
|
|||
|
|
hk.expand(HKDF_INFO, &mut wrap_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?;
|
|||
|
|
Ok(wrap_key)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 `crates/notsecrets/src/recipients/x25519.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Stanza};
|
|||
|
|
use crate::identities::x25519::derive_wrap_key;
|
|||
|
|
use crate::recipients::Recipient;
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use bech32::{Bech32, Hrp};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
use x25519_dalek::{EphemeralSecret, PublicKey};
|
|||
|
|
|
|||
|
|
const TAG: &str = "X25519";
|
|||
|
|
const RECIPIENT_HRP: &str = "age";
|
|||
|
|
|
|||
|
|
pub struct X25519Recipient {
|
|||
|
|
public_key: PublicKey,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl X25519Recipient {
|
|||
|
|
pub fn from_public_key(public_key: PublicKey) -> Self {
|
|||
|
|
Self { public_key }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Parse an `age1...` bech32 public key string.
|
|||
|
|
pub fn from_bech32(s: &str) -> Result<Self, AgeError> {
|
|||
|
|
let (hrp, data) = bech32::decode(s)
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("bech32 decode recipient: {e}")))?;
|
|||
|
|
if hrp.as_str() != RECIPIENT_HRP {
|
|||
|
|
return Err(AgeError::ParseError(format!(
|
|||
|
|
"expected hrp '{RECIPIENT_HRP}', got '{}'",
|
|||
|
|
hrp.as_str()
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
let bytes: [u8; 32] = data
|
|||
|
|
.try_into()
|
|||
|
|
.map_err(|_| AgeError::ParseError("recipient key must be 32 bytes".to_string()))?;
|
|||
|
|
Ok(Self {
|
|||
|
|
public_key: PublicKey::from(bytes),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Encode as `age1...` bech32 string.
|
|||
|
|
pub fn to_bech32(&self) -> String {
|
|||
|
|
let hrp = Hrp::parse(RECIPIENT_HRP).expect("static hrp is valid");
|
|||
|
|
bech32::encode::<Bech32>(hrp, self.public_key.as_bytes())
|
|||
|
|
.expect("bech32 encode cannot fail for valid input")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Recipient for X25519Recipient {
|
|||
|
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
|
|||
|
|
let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng);
|
|||
|
|
let ephemeral_pub = PublicKey::from(&ephemeral_secret);
|
|||
|
|
let shared = ephemeral_secret.diffie_hellman(&self.public_key);
|
|||
|
|
|
|||
|
|
let wrap_key = derive_wrap_key(
|
|||
|
|
shared.as_bytes(),
|
|||
|
|
ephemeral_pub.as_bytes(),
|
|||
|
|
self.public_key.as_bytes(),
|
|||
|
|
)?;
|
|||
|
|
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
|||
|
|
let nonce = Nonce::default(); // [0u8; 12]
|
|||
|
|
let wrapped = cipher
|
|||
|
|
.encrypt(&nonce, file_key.as_bytes().as_slice())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("X25519 file key encryption failed".to_string()))?;
|
|||
|
|
|
|||
|
|
Ok(Stanza {
|
|||
|
|
tag: TAG.to_string(),
|
|||
|
|
args: vec![STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes())],
|
|||
|
|
body: wrapped,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.4 Wire into mod files
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/identities/mod.rs`, add:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod x25519;
|
|||
|
|
pub use x25519::X25519Identity;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/recipients/mod.rs`, add:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod x25519;
|
|||
|
|
pub use x25519::X25519Recipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/lib.rs`, add to public exports:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub use identities::X25519Identity;
|
|||
|
|
pub use recipients::X25519Recipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.5 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- x25519 --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test identities::x25519::tests::x25519_bech32_parse_roundtrip ... ok
|
|||
|
|
test identities::x25519::tests::x25519_wrong_identity_returns_none_for_nonmatching_tag ... ok
|
|||
|
|
test identities::x25519::tests::x25519_wrap_unwrap_roundtrip ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 3.6 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ crates/notsecrets/src/recipients/ crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): X25519 identity and recipient with wrap/unwrap"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: Scrypt identity + recipient
|
|||
|
|
|
|||
|
|
- [ ] Create `crates/notsecrets/src/identities/scrypt.rs`
|
|||
|
|
- [ ] Create `crates/notsecrets/src/recipients/scrypt.rs`
|
|||
|
|
|
|||
|
|
### 4.1 Test (write first)
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/identities/scrypt.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::identities::FileKey;
|
|||
|
|
use crate::recipients::scrypt::ScryptRecipient;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn scrypt_wrap_unwrap_roundtrip() {
|
|||
|
|
let passphrase = "hunter2".to_string();
|
|||
|
|
let recipient = ScryptRecipient::new(passphrase.clone(), 14);
|
|||
|
|
let identity = ScryptIdentity::new(passphrase);
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x11u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
|||
|
|
assert_eq!(stanza.tag, "scrypt");
|
|||
|
|
assert_eq!(stanza.args.len(), 2); // base64(salt), work_factor
|
|||
|
|
|
|||
|
|
let unwrapped = identity
|
|||
|
|
.unwrap_file_key(&stanza)
|
|||
|
|
.expect("identity should match")
|
|||
|
|
.expect("unwrap should succeed");
|
|||
|
|
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn scrypt_wrong_passphrase_returns_crypto_error() {
|
|||
|
|
let recipient = ScryptRecipient::new("correct".to_string(), 14);
|
|||
|
|
let identity = ScryptIdentity::new("wrong".to_string());
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x11u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).unwrap();
|
|||
|
|
|
|||
|
|
let result = identity
|
|||
|
|
.unwrap_file_key(&stanza)
|
|||
|
|
.expect("tag matches")
|
|||
|
|
.expect_err("wrong passphrase should fail");
|
|||
|
|
assert!(matches!(result, crate::error::AgeError::CryptoError(_)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn scrypt_wrong_tag_returns_none() {
|
|||
|
|
let identity = ScryptIdentity::new("pass".to_string());
|
|||
|
|
use crate::identities::Stanza;
|
|||
|
|
let stanza = Stanza {
|
|||
|
|
tag: "X25519".to_string(),
|
|||
|
|
args: vec!["arg".to_string()],
|
|||
|
|
body: vec![],
|
|||
|
|
};
|
|||
|
|
assert!(identity.unwrap_file_key(&stanza).is_none());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 `crates/notsecrets/src/identities/scrypt.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Identity, Stanza};
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TAG: &str = "scrypt";
|
|||
|
|
|
|||
|
|
pub struct ScryptIdentity {
|
|||
|
|
passphrase: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl ScryptIdentity {
|
|||
|
|
pub fn new(passphrase: String) -> Self {
|
|||
|
|
Self { passphrase }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Identity for ScryptIdentity {
|
|||
|
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
|||
|
|
if stanza.tag != TAG {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
Some(unwrap(stanza, &self.passphrase))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn unwrap(stanza: &Stanza, passphrase: &str) -> Result<FileKey, AgeError> {
|
|||
|
|
if stanza.args.len() != 2 {
|
|||
|
|
return Err(AgeError::ParseError(
|
|||
|
|
"scrypt stanza must have 2 args: salt work_factor".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
let salt = STANDARD_NO_PAD
|
|||
|
|
.decode(&stanza.args[0])
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("scrypt salt base64: {e}")))?;
|
|||
|
|
let work_factor: u8 = stanza.args[1]
|
|||
|
|
.parse()
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("scrypt work factor parse: {e}")))?;
|
|||
|
|
|
|||
|
|
let wrap_key = derive_scrypt_key(passphrase.as_bytes(), &salt, work_factor)?;
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
|||
|
|
let nonce = Nonce::default();
|
|||
|
|
let file_key_bytes = cipher
|
|||
|
|
.decrypt(&nonce, stanza.body.as_slice())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("scrypt file key decryption failed".to_string()))?;
|
|||
|
|
FileKey::try_from(file_key_bytes.as_slice())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) fn derive_scrypt_key(
|
|||
|
|
passphrase: &[u8],
|
|||
|
|
salt: &[u8],
|
|||
|
|
work_factor: u8,
|
|||
|
|
) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let params = scrypt::Params::new(work_factor, 8, 1, 32)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("scrypt params: {e}")))?;
|
|||
|
|
let mut wrap_key = [0u8; 32];
|
|||
|
|
scrypt::scrypt(passphrase, salt, ¶ms, &mut wrap_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("scrypt: {e}")))?;
|
|||
|
|
Ok(wrap_key)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 `crates/notsecrets/src/recipients/scrypt.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Stanza};
|
|||
|
|
use crate::identities::scrypt::derive_scrypt_key;
|
|||
|
|
use crate::recipients::Recipient;
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use rand::RngCore;
|
|||
|
|
|
|||
|
|
const TAG: &str = "scrypt";
|
|||
|
|
const SALT_LEN: usize = 16;
|
|||
|
|
|
|||
|
|
pub struct ScryptRecipient {
|
|||
|
|
passphrase: String,
|
|||
|
|
work_factor: u8,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl ScryptRecipient {
|
|||
|
|
/// `work_factor` is the log2(N) parameter (default 18 for production; use 14 for tests).
|
|||
|
|
pub fn new(passphrase: String, work_factor: u8) -> Self {
|
|||
|
|
Self { passphrase, work_factor }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Recipient for ScryptRecipient {
|
|||
|
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
|
|||
|
|
let mut salt = [0u8; SALT_LEN];
|
|||
|
|
rand::thread_rng().fill_bytes(&mut salt);
|
|||
|
|
|
|||
|
|
let wrap_key = derive_scrypt_key(self.passphrase.as_bytes(), &salt, self.work_factor)?;
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
|||
|
|
let nonce = Nonce::default();
|
|||
|
|
let wrapped = cipher
|
|||
|
|
.encrypt(&nonce, file_key.as_bytes().as_slice())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("scrypt file key encryption failed".to_string()))?;
|
|||
|
|
|
|||
|
|
Ok(Stanza {
|
|||
|
|
tag: TAG.to_string(),
|
|||
|
|
args: vec![
|
|||
|
|
STANDARD_NO_PAD.encode(salt),
|
|||
|
|
self.work_factor.to_string(),
|
|||
|
|
],
|
|||
|
|
body: wrapped,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 Wire into mod files
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/identities/mod.rs`, add:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod scrypt;
|
|||
|
|
pub use scrypt::ScryptIdentity;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/recipients/mod.rs`, add:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod scrypt;
|
|||
|
|
pub use scrypt::ScryptRecipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `lib.rs` exports, add:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub use identities::ScryptIdentity;
|
|||
|
|
pub use recipients::ScryptRecipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.5 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- scrypt --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test identities::scrypt::tests::scrypt_wrap_unwrap_roundtrip ... ok
|
|||
|
|
test identities::scrypt::tests::scrypt_wrong_passphrase_returns_crypto_error ... ok
|
|||
|
|
test identities::scrypt::tests::scrypt_wrong_tag_returns_none ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 4.6 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/scrypt.rs crates/notsecrets/src/recipients/scrypt.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): scrypt identity and recipient (passphrase-based)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: SSH Ed25519 identity + recipient
|
|||
|
|
|
|||
|
|
- [ ] Create `crates/notsecrets/src/identities/ssh_ed25519.rs`
|
|||
|
|
- [ ] Create `crates/notsecrets/src/recipients/ssh_ed25519.rs`
|
|||
|
|
|
|||
|
|
### 5.1 Test (write first)
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/identities/ssh_ed25519.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::identities::FileKey;
|
|||
|
|
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
|
|||
|
|
|
|||
|
|
fn test_keypair() -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) {
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
let signing_key = ed25519_dalek::SigningKey::generate(&mut OsRng);
|
|||
|
|
let verifying_key = signing_key.verifying_key();
|
|||
|
|
(signing_key, verifying_key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ssh_ed25519_wrap_unwrap_roundtrip() {
|
|||
|
|
let (signing_key, verifying_key) = test_keypair();
|
|||
|
|
let recipient = SshEd25519Recipient::from_verifying_key(verifying_key);
|
|||
|
|
let identity = SshEd25519Identity::from_signing_key(signing_key);
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x33u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
|||
|
|
assert_eq!(stanza.tag, "ssh-ed25519");
|
|||
|
|
assert_eq!(stanza.args.len(), 2); // fingerprint, ephemeral_pub
|
|||
|
|
|
|||
|
|
let unwrapped = identity
|
|||
|
|
.unwrap_file_key(&stanza)
|
|||
|
|
.expect("identity should match")
|
|||
|
|
.expect("unwrap should succeed");
|
|||
|
|
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ssh_ed25519_wrong_fingerprint_returns_none() {
|
|||
|
|
let (_, verifying_key1) = test_keypair();
|
|||
|
|
let (signing_key2, _) = test_keypair();
|
|||
|
|
let recipient = SshEd25519Recipient::from_verifying_key(verifying_key1);
|
|||
|
|
let identity = SshEd25519Identity::from_signing_key(signing_key2);
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x33u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).unwrap();
|
|||
|
|
// Different key — fingerprint check in unwrap_file_key should return None
|
|||
|
|
assert!(identity.unwrap_file_key(&stanza).is_none());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 `crates/notsecrets/src/identities/ssh_ed25519.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Identity, Stanza};
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use ed25519_dalek::SigningKey;
|
|||
|
|
use hkdf::Hkdf;
|
|||
|
|
use sha2::{Digest, Sha256, Sha512};
|
|||
|
|
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
|
|||
|
|
|
|||
|
|
const TAG: &str = "ssh-ed25519";
|
|||
|
|
const HKDF_INFO: &[u8] = b"age-encryption.org/v1/ssh-ed25519";
|
|||
|
|
|
|||
|
|
pub struct SshEd25519Identity {
|
|||
|
|
signing_key: SigningKey,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SshEd25519Identity {
|
|||
|
|
pub fn from_signing_key(signing_key: SigningKey) -> Self {
|
|||
|
|
Self { signing_key }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn x25519_secret(&self) -> X25519StaticSecret {
|
|||
|
|
// Ed25519 -> X25519 conversion: clamp(SHA-512(seed)[..32])
|
|||
|
|
let mut hash = Sha512::digest(self.signing_key.as_bytes());
|
|||
|
|
hash[0] &= 248;
|
|||
|
|
hash[31] &= 127;
|
|||
|
|
hash[31] |= 64;
|
|||
|
|
let bytes: [u8; 32] = hash[..32].try_into().expect("SHA-512 output is 64 bytes");
|
|||
|
|
X25519StaticSecret::from(bytes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn fingerprint(&self) -> [u8; 4] {
|
|||
|
|
ssh_key_fingerprint(self.signing_key.verifying_key().as_bytes())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Identity for SshEd25519Identity {
|
|||
|
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
|||
|
|
if stanza.tag != TAG {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
if stanza.args.len() != 2 {
|
|||
|
|
return Some(Err(AgeError::ParseError(
|
|||
|
|
"ssh-ed25519 stanza must have 2 args".to_string(),
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
// Check fingerprint
|
|||
|
|
let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
|
|||
|
|
Ok(b) => b,
|
|||
|
|
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
|
|||
|
|
};
|
|||
|
|
if fp_bytes != self.fingerprint() {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
Some(unwrap(stanza, self))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result<FileKey, AgeError> {
|
|||
|
|
let ephemeral_pub_bytes = STANDARD_NO_PAD
|
|||
|
|
.decode(&stanza.args[1])
|
|||
|
|
.map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?;
|
|||
|
|
let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes
|
|||
|
|
.try_into()
|
|||
|
|
.map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?;
|
|||
|
|
let ephemeral_pub = X25519PublicKey::from(ephemeral_pub_bytes);
|
|||
|
|
|
|||
|
|
let x25519_secret = identity.x25519_secret();
|
|||
|
|
let shared = x25519_secret.diffie_hellman(&ephemeral_pub);
|
|||
|
|
|
|||
|
|
let wrap_key = derive_wrap_key(
|
|||
|
|
shared.as_bytes(),
|
|||
|
|
ephemeral_pub.as_bytes(),
|
|||
|
|
X25519PublicKey::from(&x25519_secret).as_bytes(),
|
|||
|
|
)?;
|
|||
|
|
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
|||
|
|
let nonce = Nonce::default();
|
|||
|
|
let file_key_bytes = cipher
|
|||
|
|
.decrypt(&nonce, stanza.body.as_slice())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("ssh-ed25519 file key decryption failed".to_string()))?;
|
|||
|
|
FileKey::try_from(file_key_bytes.as_slice())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) fn ssh_key_fingerprint(pub_key_bytes: &[u8]) -> [u8; 4] {
|
|||
|
|
let hash = Sha256::digest(pub_key_bytes);
|
|||
|
|
hash[..4].try_into().expect("SHA-256 output is 32 bytes")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) fn derive_wrap_key(
|
|||
|
|
shared: &[u8],
|
|||
|
|
ephemeral_pub: &[u8],
|
|||
|
|
recipient_pub: &[u8],
|
|||
|
|
) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len());
|
|||
|
|
ikm.extend_from_slice(shared);
|
|||
|
|
ikm.extend_from_slice(ephemeral_pub);
|
|||
|
|
ikm.extend_from_slice(recipient_pub);
|
|||
|
|
|
|||
|
|
let hk = Hkdf::<Sha256>::new(None, &ikm);
|
|||
|
|
let mut wrap_key = [0u8; 32];
|
|||
|
|
hk.expand(HKDF_INFO, &mut wrap_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?;
|
|||
|
|
Ok(wrap_key)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 `crates/notsecrets/src/recipients/ssh_ed25519.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Stanza};
|
|||
|
|
use crate::identities::ssh_ed25519::{derive_wrap_key, ssh_key_fingerprint};
|
|||
|
|
use crate::recipients::Recipient;
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use ed25519_dalek::VerifyingKey;
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey};
|
|||
|
|
|
|||
|
|
const TAG: &str = "ssh-ed25519";
|
|||
|
|
|
|||
|
|
pub struct SshEd25519Recipient {
|
|||
|
|
verifying_key: VerifyingKey,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SshEd25519Recipient {
|
|||
|
|
pub fn from_verifying_key(verifying_key: VerifyingKey) -> Self {
|
|||
|
|
Self { verifying_key }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn x25519_public(&self) -> X25519PublicKey {
|
|||
|
|
// Ed25519 pubkey -> X25519 pubkey via birational map
|
|||
|
|
// curve25519_dalek provides this via EdwardsPoint::to_montgomery()
|
|||
|
|
use curve25519_dalek::edwards::CompressedEdwardsY;
|
|||
|
|
let compressed = CompressedEdwardsY(self.verifying_key.to_bytes());
|
|||
|
|
let point = compressed.decompress().expect("valid ed25519 pubkey decompresses");
|
|||
|
|
X25519PublicKey::from(point.to_montgomery().to_bytes())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Recipient for SshEd25519Recipient {
|
|||
|
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
|
|||
|
|
let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng);
|
|||
|
|
let ephemeral_pub = X25519PublicKey::from(&ephemeral_secret);
|
|||
|
|
let recipient_x25519 = self.x25519_public();
|
|||
|
|
let shared = ephemeral_secret.diffie_hellman(&recipient_x25519);
|
|||
|
|
|
|||
|
|
let wrap_key = derive_wrap_key(
|
|||
|
|
shared.as_bytes(),
|
|||
|
|
ephemeral_pub.as_bytes(),
|
|||
|
|
recipient_x25519.as_bytes(),
|
|||
|
|
)?;
|
|||
|
|
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
|||
|
|
let nonce = Nonce::default();
|
|||
|
|
let wrapped = cipher
|
|||
|
|
.encrypt(&nonce, file_key.as_bytes().as_slice())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("ssh-ed25519 file key encryption failed".to_string()))?;
|
|||
|
|
|
|||
|
|
let fingerprint = ssh_key_fingerprint(self.verifying_key.as_bytes());
|
|||
|
|
|
|||
|
|
Ok(Stanza {
|
|||
|
|
tag: TAG.to_string(),
|
|||
|
|
args: vec![
|
|||
|
|
STANDARD_NO_PAD.encode(fingerprint),
|
|||
|
|
STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes()),
|
|||
|
|
],
|
|||
|
|
body: wrapped,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add `curve25519-dalek = "4"` to `[dependencies]` in `crates/notsecrets/Cargo.toml`.
|
|||
|
|
|
|||
|
|
### 5.4 Wire into mod files
|
|||
|
|
|
|||
|
|
In `identities/mod.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod ssh_ed25519;
|
|||
|
|
pub use ssh_ed25519::SshEd25519Identity;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `recipients/mod.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod ssh_ed25519;
|
|||
|
|
pub use ssh_ed25519::SshEd25519Recipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `lib.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub use identities::SshEd25519Identity;
|
|||
|
|
pub use recipients::SshEd25519Recipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.5 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- ssh_ed25519 --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test identities::ssh_ed25519::tests::ssh_ed25519_wrap_unwrap_roundtrip ... ok
|
|||
|
|
test identities::ssh_ed25519::tests::ssh_ed25519_wrong_fingerprint_returns_none ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 5.6 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ssh_ed25519.rs crates/notsecrets/src/recipients/ssh_ed25519.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs crates/notsecrets/Cargo.toml
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): SSH Ed25519 identity and recipient"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: SSH RSA identity + recipient
|
|||
|
|
|
|||
|
|
- [ ] Create `crates/notsecrets/src/identities/ssh_rsa.rs`
|
|||
|
|
- [ ] Create `crates/notsecrets/src/recipients/ssh_rsa.rs`
|
|||
|
|
|
|||
|
|
### 6.1 Test (write first)
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/identities/ssh_rsa.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::identities::FileKey;
|
|||
|
|
use crate::recipients::ssh_rsa::SshRsaRecipient;
|
|||
|
|
use rsa::{RsaPrivateKey, RsaPublicKey};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
|
|||
|
|
// 2048-bit key; use a small bit size in tests only via feature flag or constant
|
|||
|
|
let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("RSA keygen failed");
|
|||
|
|
let public_key = RsaPublicKey::from(&private_key);
|
|||
|
|
(private_key, public_key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ssh_rsa_wrap_unwrap_roundtrip() {
|
|||
|
|
let (private_key, public_key) = test_rsa_keypair();
|
|||
|
|
let recipient = SshRsaRecipient::from_public_key(public_key);
|
|||
|
|
let identity = SshRsaIdentity::from_private_key(private_key);
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x55u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
|||
|
|
assert_eq!(stanza.tag, "ssh-rsa");
|
|||
|
|
assert_eq!(stanza.args.len(), 1); // fingerprint
|
|||
|
|
|
|||
|
|
let unwrapped = identity
|
|||
|
|
.unwrap_file_key(&stanza)
|
|||
|
|
.expect("identity should match")
|
|||
|
|
.expect("unwrap should succeed");
|
|||
|
|
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ssh_rsa_wrong_fingerprint_returns_none() {
|
|||
|
|
let (private_key1, _) = test_rsa_keypair();
|
|||
|
|
let (_, public_key2) = test_rsa_keypair();
|
|||
|
|
let recipient = SshRsaRecipient::from_public_key(public_key2);
|
|||
|
|
let identity = SshRsaIdentity::from_private_key(private_key1);
|
|||
|
|
|
|||
|
|
let file_key = FileKey::new([0x55u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).unwrap();
|
|||
|
|
// Fingerprint mismatch → None
|
|||
|
|
assert!(identity.unwrap_file_key(&stanza).is_none());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.2 `crates/notsecrets/src/identities/ssh_rsa.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Identity, Stanza};
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use rsa::{Oaep, RsaPrivateKey};
|
|||
|
|
use sha2::{Digest, Sha256};
|
|||
|
|
|
|||
|
|
const TAG: &str = "ssh-rsa";
|
|||
|
|
|
|||
|
|
pub struct SshRsaIdentity {
|
|||
|
|
private_key: RsaPrivateKey,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SshRsaIdentity {
|
|||
|
|
pub fn from_private_key(private_key: RsaPrivateKey) -> Self {
|
|||
|
|
Self { private_key }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn fingerprint(&self) -> [u8; 4] {
|
|||
|
|
use rsa::traits::PublicKeyParts;
|
|||
|
|
rsa_pubkey_fingerprint(&rsa::RsaPublicKey::from(&self.private_key))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Identity for SshRsaIdentity {
|
|||
|
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
|||
|
|
if stanza.tag != TAG {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
if stanza.args.len() != 1 {
|
|||
|
|
return Some(Err(AgeError::ParseError(
|
|||
|
|
"ssh-rsa stanza must have 1 arg".to_string(),
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
|
|||
|
|
Ok(b) => b,
|
|||
|
|
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
|
|||
|
|
};
|
|||
|
|
if fp != self.fingerprint() {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
Some(unwrap(stanza, &self.private_key))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result<FileKey, AgeError> {
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
let padding = Oaep::new::<Sha256>();
|
|||
|
|
let file_key_bytes = private_key
|
|||
|
|
.decrypt(padding, &stanza.body)
|
|||
|
|
.map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?;
|
|||
|
|
FileKey::try_from(file_key_bytes.as_slice())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) fn rsa_pubkey_fingerprint(public_key: &rsa::RsaPublicKey) -> [u8; 4] {
|
|||
|
|
use rsa::traits::PublicKeyParts;
|
|||
|
|
// Fingerprint: first 4 bytes of SHA-256 of the DER-encoded public key bytes
|
|||
|
|
let n_bytes = public_key.n().to_bytes_be();
|
|||
|
|
let e_bytes = public_key.e().to_bytes_be();
|
|||
|
|
let mut hasher = Sha256::new();
|
|||
|
|
hasher.update(&n_bytes);
|
|||
|
|
hasher.update(&e_bytes);
|
|||
|
|
let hash = hasher.finalize();
|
|||
|
|
hash[..4].try_into().expect("SHA-256 is 32 bytes")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 `crates/notsecrets/src/recipients/ssh_rsa.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Stanza};
|
|||
|
|
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
|
|||
|
|
use crate::recipients::Recipient;
|
|||
|
|
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|||
|
|
use rsa::{Oaep, RsaPublicKey};
|
|||
|
|
use sha2::Sha256;
|
|||
|
|
|
|||
|
|
const TAG: &str = "ssh-rsa";
|
|||
|
|
|
|||
|
|
pub struct SshRsaRecipient {
|
|||
|
|
public_key: RsaPublicKey,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SshRsaRecipient {
|
|||
|
|
pub fn from_public_key(public_key: RsaPublicKey) -> Self {
|
|||
|
|
Self { public_key }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Recipient for SshRsaRecipient {
|
|||
|
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
let padding = Oaep::new::<Sha256>();
|
|||
|
|
let wrapped = self
|
|||
|
|
.public_key
|
|||
|
|
.encrypt(&mut OsRng, padding, file_key.as_bytes())
|
|||
|
|
.map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?;
|
|||
|
|
|
|||
|
|
let fingerprint = rsa_pubkey_fingerprint(&self.public_key);
|
|||
|
|
|
|||
|
|
Ok(Stanza {
|
|||
|
|
tag: TAG.to_string(),
|
|||
|
|
args: vec![STANDARD_NO_PAD.encode(fingerprint)],
|
|||
|
|
body: wrapped,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.4 Wire into mod files
|
|||
|
|
|
|||
|
|
In `identities/mod.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod ssh_rsa;
|
|||
|
|
pub use ssh_rsa::SshRsaIdentity;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `recipients/mod.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod ssh_rsa;
|
|||
|
|
pub use ssh_rsa::SshRsaRecipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `lib.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub use identities::SshRsaIdentity;
|
|||
|
|
pub use recipients::SshRsaRecipient;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.5 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- ssh_rsa --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test identities::ssh_rsa::tests::ssh_rsa_wrap_unwrap_roundtrip ... ok
|
|||
|
|
test identities::ssh_rsa::tests::ssh_rsa_wrong_fingerprint_returns_none ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Note: RSA keygen at 2048 bits is slow (~1s per test). This is expected in CI.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 6.6 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ssh_rsa.rs crates/notsecrets/src/recipients/ssh_rsa.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): SSH RSA identity and recipient (OAEP-SHA256)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: EncryptedIdentity
|
|||
|
|
|
|||
|
|
An `EncryptedIdentity` wraps another `Identity` whose key material is stored in an age-encrypted file. Decrypting the outer file yields the inner identity's key material (e.g., an `AGE-SECRET-KEY-1...` line).
|
|||
|
|
|
|||
|
|
- [ ] Create `crates/notsecrets/src/identities/encrypted.rs`
|
|||
|
|
|
|||
|
|
### 7.1 Test (write first)
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::identities::{FileKey, x25519::X25519Identity};
|
|||
|
|
use crate::recipients::x25519::X25519Recipient;
|
|||
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
/// Build a minimal age-encrypted file wrapping an AGE-SECRET-KEY-1 identity.
|
|||
|
|
fn make_encrypted_identity_file(inner_bech32: &str, passphrase: &str) -> Vec<u8> {
|
|||
|
|
use crate::recipients::scrypt::ScryptRecipient;
|
|||
|
|
let recipient = ScryptRecipient::new(passphrase.to_string(), 14);
|
|||
|
|
// Encrypt the inner key as plaintext
|
|||
|
|
let encryptor = crate::Encryptor::with_recipients(vec![Box::new(recipient)])
|
|||
|
|
.expect("encryptor build");
|
|||
|
|
encryptor
|
|||
|
|
.encrypt(inner_bech32.as_bytes())
|
|||
|
|
.expect("encrypt inner key")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encrypted_identity_wraps_x25519() {
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
let inner_secret = StaticSecret::random_from_rng(OsRng);
|
|||
|
|
let inner_pub = PublicKey::from(&inner_secret);
|
|||
|
|
let inner_identity = X25519Identity::from_static_secret(inner_secret.clone());
|
|||
|
|
let inner_bech32 = inner_identity.to_bech32();
|
|||
|
|
|
|||
|
|
let passphrase = "test-passphrase";
|
|||
|
|
let encrypted_file = make_encrypted_identity_file(&inner_bech32, passphrase);
|
|||
|
|
|
|||
|
|
// Wrap in EncryptedIdentity
|
|||
|
|
let encrypted_identity = EncryptedIdentity::new(
|
|||
|
|
encrypted_file,
|
|||
|
|
Box::new(crate::identities::scrypt::ScryptIdentity::new(passphrase.to_string())),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Encrypt a payload with the inner X25519 recipient
|
|||
|
|
let recipient = X25519Recipient::from_public_key(inner_pub);
|
|||
|
|
let file_key = FileKey::new([0x77u8; 16]);
|
|||
|
|
let stanza = recipient.wrap_file_key(&file_key).unwrap();
|
|||
|
|
|
|||
|
|
// EncryptedIdentity should decrypt the file to recover the inner identity and unwrap
|
|||
|
|
let unwrapped = encrypted_identity
|
|||
|
|
.unwrap_file_key(&stanza)
|
|||
|
|
.expect("should match")
|
|||
|
|
.expect("should decrypt");
|
|||
|
|
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Note: this test depends on `Encryptor` from Task 8. Mark it `#[ignore]` until Task 8 is complete, then remove the `#[ignore]`.
|
|||
|
|
|
|||
|
|
### 7.2 `crates/notsecrets/src/identities/encrypted.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::{FileKey, Identity, Stanza};
|
|||
|
|
|
|||
|
|
/// An identity whose key material is stored in an age-encrypted file.
|
|||
|
|
///
|
|||
|
|
/// The outer file is decrypted on each call to `unwrap_file_key` using
|
|||
|
|
/// `passphrase_identity`. The decrypted content must be an `AGE-SECRET-KEY-1...`
|
|||
|
|
/// bech32 string, which is parsed as an `X25519Identity`.
|
|||
|
|
pub struct EncryptedIdentity {
|
|||
|
|
/// Raw bytes of the age-encrypted identity file.
|
|||
|
|
encrypted_data: Vec<u8>,
|
|||
|
|
/// Identity used to decrypt the outer age file (typically `ScryptIdentity`).
|
|||
|
|
passphrase_identity: Box<dyn Identity>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl EncryptedIdentity {
|
|||
|
|
pub fn new(encrypted_data: Vec<u8>, passphrase_identity: Box<dyn Identity>) -> Self {
|
|||
|
|
Self { encrypted_data, passphrase_identity }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Identity for EncryptedIdentity {
|
|||
|
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
|||
|
|
// Decrypt the outer file to recover inner identity key bytes
|
|||
|
|
let decryptor = crate::Decryptor::with_identities(vec![self.passphrase_identity.as_ref()]);
|
|||
|
|
let plaintext = match decryptor.decrypt(&self.encrypted_data) {
|
|||
|
|
Ok(p) => p,
|
|||
|
|
Err(e) => return Some(Err(e)),
|
|||
|
|
};
|
|||
|
|
let key_str = match std::str::from_utf8(&plaintext) {
|
|||
|
|
Ok(s) => s.trim().to_string(),
|
|||
|
|
Err(_) => {
|
|||
|
|
return Some(Err(AgeError::ParseError(
|
|||
|
|
"decrypted identity file is not valid UTF-8".to_string(),
|
|||
|
|
)))
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
// Parse inner identity — only X25519 supported for now
|
|||
|
|
let inner: Box<dyn Identity> = if key_str.starts_with("AGE-SECRET-KEY-1") {
|
|||
|
|
match crate::identities::x25519::X25519Identity::from_bech32(&key_str) {
|
|||
|
|
Ok(id) => Box::new(id),
|
|||
|
|
Err(e) => return Some(Err(e)),
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
return Some(Err(AgeError::UnsupportedKeyType(
|
|||
|
|
"encrypted identity file must contain AGE-SECRET-KEY-1".to_string(),
|
|||
|
|
)));
|
|||
|
|
};
|
|||
|
|
inner.unwrap_file_key(stanza)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Note: `Decryptor::with_identities` here takes `Vec<&dyn Identity>` rather than `Vec<Box<dyn Identity>>` to avoid clone requirements. The `Decryptor` signature in Task 9 must accommodate both — the simplest approach is to add a `with_identity_refs` constructor, or to make `EncryptedIdentity` clone the passphrase identity. The plan uses the latter: require `passphrase_identity: Box<dyn Identity + Send>` and rely on the fact that `ScryptIdentity` is `Clone`. Adjust `Decryptor` API in Task 9 to accept `&[Box<dyn Identity>]` or provide a `decrypt_with` method. The exact API reconciliation is resolved in Task 9.
|
|||
|
|
|
|||
|
|
### 7.3 Wire into mod files
|
|||
|
|
|
|||
|
|
In `identities/mod.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub mod encrypted;
|
|||
|
|
pub use encrypted::EncryptedIdentity;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `lib.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub use identities::EncryptedIdentity;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.4 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- encrypted --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: `EncryptedIdentity` tests are marked `#[ignore]` pending Task 8; remaining tests pass.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 7.5 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/encrypted.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): EncryptedIdentity — passphrase-protected identity file wrapper"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: Encryptor
|
|||
|
|
|
|||
|
|
- [ ] Add `crates/notsecrets/src/encrypt.rs` with `Encryptor`
|
|||
|
|
- [ ] Wire into `lib.rs`
|
|||
|
|
|
|||
|
|
### 8.1 Test (write first)
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::identities::FileKey;
|
|||
|
|
use crate::identities::x25519::X25519Identity;
|
|||
|
|
use crate::recipients::x25519::X25519Recipient;
|
|||
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encryptor_single_x25519_recipient() {
|
|||
|
|
let secret = StaticSecret::random_from_rng(OsRng);
|
|||
|
|
let public = PublicKey::from(&secret);
|
|||
|
|
let recipient: Box<dyn crate::recipients::Recipient> =
|
|||
|
|
Box::new(X25519Recipient::from_public_key(public));
|
|||
|
|
|
|||
|
|
let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap();
|
|||
|
|
let plaintext = b"hello, age!";
|
|||
|
|
let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed");
|
|||
|
|
|
|||
|
|
// Verify it starts with the age version line
|
|||
|
|
assert!(
|
|||
|
|
ciphertext.starts_with(b"age-encryption.org/v1\n"),
|
|||
|
|
"output must start with age version line"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encryptor_rejects_multiple_scrypt_recipients() {
|
|||
|
|
use crate::recipients::scrypt::ScryptRecipient;
|
|||
|
|
let r1: Box<dyn crate::recipients::Recipient> =
|
|||
|
|
Box::new(ScryptRecipient::new("pass1".to_string(), 14));
|
|||
|
|
let r2: Box<dyn crate::recipients::Recipient> =
|
|||
|
|
Box::new(ScryptRecipient::new("pass2".to_string(), 14));
|
|||
|
|
let result = Encryptor::with_recipients(vec![r1, r2]);
|
|||
|
|
assert!(result.is_err(), "two scrypt recipients must be rejected");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encryptor_empty_recipients_returns_error() {
|
|||
|
|
let result = Encryptor::with_recipients(vec![]);
|
|||
|
|
assert!(result.is_err(), "empty recipients must be rejected");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 8.2 `crates/notsecrets/src/encrypt.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::format::{serialize_header};
|
|||
|
|
use crate::identities::{FileKey, Header};
|
|||
|
|
use crate::recipients::Recipient;
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use hkdf::Hkdf;
|
|||
|
|
use hmac::{Hmac, Mac};
|
|||
|
|
use rand::RngCore;
|
|||
|
|
use sha2::Sha256;
|
|||
|
|
|
|||
|
|
type HmacSha256 = Hmac<Sha256>;
|
|||
|
|
|
|||
|
|
pub struct Encryptor {
|
|||
|
|
recipients: Vec<Box<dyn Recipient>>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Encryptor {
|
|||
|
|
/// Construct an `Encryptor`. Returns `AgeError` if:
|
|||
|
|
/// - `recipients` is empty
|
|||
|
|
/// - more than one scrypt recipient is present
|
|||
|
|
pub fn with_recipients(recipients: Vec<Box<dyn Recipient>>) -> Result<Self, AgeError> {
|
|||
|
|
if recipients.is_empty() {
|
|||
|
|
return Err(AgeError::CryptoError(
|
|||
|
|
"at least one recipient is required".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
// Detect scrypt recipients by attempting to wrap a dummy key and checking the stanza tag.
|
|||
|
|
// We cannot inspect the concrete type, so we use a probe approach:
|
|||
|
|
// wrap a dummy FileKey and check if any stanza tag is "scrypt".
|
|||
|
|
let dummy_key = FileKey::new([0u8; 16]);
|
|||
|
|
let scrypt_count = recipients
|
|||
|
|
.iter()
|
|||
|
|
.filter_map(|r| r.wrap_file_key(&dummy_key).ok())
|
|||
|
|
.filter(|s| s.tag == "scrypt")
|
|||
|
|
.count();
|
|||
|
|
if scrypt_count > 1 {
|
|||
|
|
return Err(AgeError::CryptoError(
|
|||
|
|
"at most one scrypt recipient is allowed per age file".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
Ok(Self { recipients })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, AgeError> {
|
|||
|
|
let file_key = FileKey::generate();
|
|||
|
|
|
|||
|
|
// Wrap file key with each recipient
|
|||
|
|
let stanzas: Vec<_> = self
|
|||
|
|
.recipients
|
|||
|
|
.iter()
|
|||
|
|
.map(|r| r.wrap_file_key(&file_key))
|
|||
|
|
.collect::<Result<_, _>>()?;
|
|||
|
|
|
|||
|
|
// Build header without MAC first (we need the bytes to compute MAC)
|
|||
|
|
let header_no_mac = Header {
|
|||
|
|
recipients: stanzas,
|
|||
|
|
mac: Vec::new(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Compute MAC: HMAC-SHA256 over header bytes up to and including "--- "
|
|||
|
|
let mut header_bytes = serialize_header(&header_no_mac);
|
|||
|
|
// The serialized header ends with "--- \n" (MAC is empty, so base64("") = "").
|
|||
|
|
// We need the MAC to cover bytes up to and including "--- ".
|
|||
|
|
// Trim the trailing "\n" that follows the empty MAC, then compute MAC over
|
|||
|
|
// everything up to and including "--- ".
|
|||
|
|
let mac_key = derive_mac_key(&file_key)?;
|
|||
|
|
let footer_pos = header_bytes
|
|||
|
|
.windows(4)
|
|||
|
|
.rposition(|w| w == b"--- ")
|
|||
|
|
.ok_or_else(|| AgeError::ParseError("serialize_header missing footer".to_string()))?;
|
|||
|
|
let mac_input = &header_bytes[..footer_pos + 4]; // up to and including "--- "
|
|||
|
|
|
|||
|
|
let mut mac = HmacSha256::new_from_slice(&mac_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?;
|
|||
|
|
mac.update(mac_input);
|
|||
|
|
let mac_bytes = mac.finalize().into_bytes().to_vec();
|
|||
|
|
|
|||
|
|
// Re-serialize with actual MAC
|
|||
|
|
let header_with_mac = Header {
|
|||
|
|
recipients: header_no_mac.recipients,
|
|||
|
|
mac: mac_bytes,
|
|||
|
|
};
|
|||
|
|
header_bytes = serialize_header(&header_with_mac);
|
|||
|
|
|
|||
|
|
// Payload: 16-byte random nonce + ChaCha20Poly1305(payload_key, plaintext)
|
|||
|
|
let mut nonce_bytes = [0u8; 16];
|
|||
|
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|||
|
|
let payload_key = derive_payload_key(&file_key, &nonce_bytes)?;
|
|||
|
|
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key));
|
|||
|
|
let counter_nonce = Nonce::default(); // [0u8; 12]
|
|||
|
|
let ciphertext = cipher
|
|||
|
|
.encrypt(&counter_nonce, plaintext)
|
|||
|
|
.map_err(|_| AgeError::CryptoError("payload encryption failed".to_string()))?;
|
|||
|
|
|
|||
|
|
let mut output = header_bytes;
|
|||
|
|
output.extend_from_slice(&nonce_bytes);
|
|||
|
|
output.extend_from_slice(&ciphertext);
|
|||
|
|
|
|||
|
|
Ok(output)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let hk = Hkdf::<Sha256>::new(Some(b""), file_key.as_bytes());
|
|||
|
|
let mut mac_key = [0u8; 32];
|
|||
|
|
hk.expand(b"header", &mut mac_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?;
|
|||
|
|
Ok(mac_key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let hk = Hkdf::<Sha256>::new(Some(nonce.as_slice()), file_key.as_bytes());
|
|||
|
|
let mut payload_key = [0u8; 32];
|
|||
|
|
hk.expand(b"payload", &mut payload_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?;
|
|||
|
|
Ok(payload_key)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add `pub mod encrypt;` and `pub use encrypt::Encryptor;` to `lib.rs`.
|
|||
|
|
|
|||
|
|
### 8.3 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- encryptor --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test encrypt::tests::encryptor_single_x25519_recipient ... ok
|
|||
|
|
test encrypt::tests::encryptor_rejects_multiple_scrypt_recipients ... ok
|
|||
|
|
test encrypt::tests::encryptor_empty_recipients_returns_error ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Also run the `encrypted` test which was previously ignored — it now has `Encryptor` available:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- encrypted --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Remove the `#[ignore]` attribute from `encrypted` tests and verify they pass.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 8.4 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/encrypt.rs crates/notsecrets/src/identities/encrypted.rs crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): Encryptor — multi-recipient age encryption"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 9: Decryptor
|
|||
|
|
|
|||
|
|
- [ ] Add `crates/notsecrets/src/decrypt.rs` with `Decryptor`
|
|||
|
|
- [ ] Wire into `lib.rs`
|
|||
|
|
|
|||
|
|
### 9.1 Test (write first)
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::Encryptor;
|
|||
|
|
use crate::identities::x25519::X25519Identity;
|
|||
|
|
use crate::recipients::x25519::X25519Recipient;
|
|||
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
|
|||
|
|
fn make_x25519_pair() -> (X25519Identity, X25519Recipient) {
|
|||
|
|
let secret = StaticSecret::random_from_rng(OsRng);
|
|||
|
|
let public = PublicKey::from(&secret);
|
|||
|
|
(
|
|||
|
|
X25519Identity::from_static_secret(secret),
|
|||
|
|
X25519Recipient::from_public_key(public),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn decryptor_roundtrip_single_recipient() {
|
|||
|
|
let (identity, recipient) = make_x25519_pair();
|
|||
|
|
let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
|
|||
|
|
let plaintext = b"the quick brown fox";
|
|||
|
|
let ciphertext = encryptor.encrypt(plaintext).unwrap();
|
|||
|
|
|
|||
|
|
let decryptor = Decryptor::with_identities(vec![Box::new(identity)]);
|
|||
|
|
let decrypted = decryptor.decrypt(&ciphertext).unwrap();
|
|||
|
|
assert_eq!(decrypted, plaintext);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn decryptor_roundtrip_multiple_recipients() {
|
|||
|
|
let (identity1, recipient1) = make_x25519_pair();
|
|||
|
|
let (identity2, recipient2) = make_x25519_pair();
|
|||
|
|
let encryptor = Encryptor::with_recipients(vec![
|
|||
|
|
Box::new(recipient1),
|
|||
|
|
Box::new(recipient2),
|
|||
|
|
])
|
|||
|
|
.unwrap();
|
|||
|
|
let plaintext = b"multi-recipient test";
|
|||
|
|
let ciphertext = encryptor.encrypt(plaintext).unwrap();
|
|||
|
|
|
|||
|
|
// Either identity alone can decrypt
|
|||
|
|
let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]);
|
|||
|
|
assert_eq!(decryptor1.decrypt(&ciphertext).unwrap(), plaintext);
|
|||
|
|
|
|||
|
|
let decryptor2 = Decryptor::with_identities(vec![Box::new(identity2)]);
|
|||
|
|
assert_eq!(decryptor2.decrypt(&ciphertext).unwrap(), plaintext);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn decryptor_no_matching_identity_returns_no_match() {
|
|||
|
|
let (_, recipient) = make_x25519_pair();
|
|||
|
|
let (wrong_identity, _) = make_x25519_pair();
|
|||
|
|
let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
|
|||
|
|
let ciphertext = encryptor.encrypt(b"data").unwrap();
|
|||
|
|
|
|||
|
|
let decryptor = Decryptor::with_identities(vec![Box::new(wrong_identity)]);
|
|||
|
|
let err = decryptor.decrypt(&ciphertext).unwrap_err();
|
|||
|
|
assert!(matches!(err, crate::error::AgeError::NoMatch));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn decryptor_corrupted_mac_returns_mac_mismatch() {
|
|||
|
|
let (identity, recipient) = make_x25519_pair();
|
|||
|
|
let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
|
|||
|
|
let mut ciphertext = encryptor.encrypt(b"data").unwrap();
|
|||
|
|
|
|||
|
|
// Flip a byte in the MAC region (near end of header, before payload)
|
|||
|
|
// Find "--- " and flip a byte 5 bytes after it
|
|||
|
|
let footer_pos = ciphertext
|
|||
|
|
.windows(4)
|
|||
|
|
.position(|w| w == b"--- ")
|
|||
|
|
.expect("footer present");
|
|||
|
|
ciphertext[footer_pos + 4] ^= 0xff;
|
|||
|
|
|
|||
|
|
let decryptor = Decryptor::with_identities(vec![Box::new(identity)]);
|
|||
|
|
let err = decryptor.decrypt(&ciphertext).unwrap_err();
|
|||
|
|
assert!(
|
|||
|
|
matches!(err, crate::error::AgeError::MacMismatch | crate::error::AgeError::ParseError(_)),
|
|||
|
|
"expected MacMismatch or ParseError, got: {err:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 9.2 `crates/notsecrets/src/decrypt.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::format::{header_bytes_up_to_footer, parse_header};
|
|||
|
|
use crate::identities::{FileKey, Identity};
|
|||
|
|
use chacha20poly1305::{
|
|||
|
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
|||
|
|
aead::Aead,
|
|||
|
|
};
|
|||
|
|
use hkdf::Hkdf;
|
|||
|
|
use hmac::{Hmac, Mac};
|
|||
|
|
use sha2::Sha256;
|
|||
|
|
|
|||
|
|
type HmacSha256 = Hmac<Sha256>;
|
|||
|
|
|
|||
|
|
pub struct Decryptor {
|
|||
|
|
identities: Vec<Box<dyn Identity>>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Decryptor {
|
|||
|
|
pub fn with_identities(identities: Vec<Box<dyn Identity>>) -> Self {
|
|||
|
|
Self { identities }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, AgeError> {
|
|||
|
|
let (header, payload_offset) = parse_header(ciphertext)?;
|
|||
|
|
|
|||
|
|
// Try to recover file key by trying each identity against each stanza
|
|||
|
|
let mut file_key: Option<FileKey> = None;
|
|||
|
|
'outer: for identity in &self.identities {
|
|||
|
|
for stanza in &header.recipients {
|
|||
|
|
if let Some(result) = identity.unwrap_file_key(stanza) {
|
|||
|
|
file_key = Some(result?);
|
|||
|
|
break 'outer;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let file_key = file_key.ok_or(AgeError::NoMatch)?;
|
|||
|
|
|
|||
|
|
// Verify header MAC
|
|||
|
|
let mac_key = derive_mac_key(&file_key)?;
|
|||
|
|
let header_input = header_bytes_up_to_footer(ciphertext)?;
|
|||
|
|
|
|||
|
|
let mut mac = HmacSha256::new_from_slice(&mac_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?;
|
|||
|
|
mac.update(&header_input);
|
|||
|
|
mac.verify_slice(&header.mac)
|
|||
|
|
.map_err(|_| AgeError::MacMismatch)?;
|
|||
|
|
|
|||
|
|
// Decrypt payload
|
|||
|
|
let payload = &ciphertext[payload_offset..];
|
|||
|
|
if payload.len() < 16 {
|
|||
|
|
return Err(AgeError::ParseError(
|
|||
|
|
"payload too short to contain nonce".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
let nonce_bytes: [u8; 16] = payload[..16]
|
|||
|
|
.try_into()
|
|||
|
|
.expect("slice is exactly 16 bytes");
|
|||
|
|
let payload_ciphertext = &payload[16..];
|
|||
|
|
|
|||
|
|
let payload_key = derive_payload_key(&file_key, &nonce_bytes)?;
|
|||
|
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key));
|
|||
|
|
let counter_nonce = Nonce::default();
|
|||
|
|
let plaintext = cipher
|
|||
|
|
.decrypt(&counter_nonce, payload_ciphertext)
|
|||
|
|
.map_err(|_| AgeError::CryptoError("payload decryption failed".to_string()))?;
|
|||
|
|
|
|||
|
|
Ok(plaintext)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let hk = Hkdf::<Sha256>::new(Some(b""), file_key.as_bytes());
|
|||
|
|
let mut mac_key = [0u8; 32];
|
|||
|
|
hk.expand(b"header", &mut mac_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?;
|
|||
|
|
Ok(mac_key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> {
|
|||
|
|
let hk = Hkdf::<Sha256>::new(Some(nonce.as_slice()), file_key.as_bytes());
|
|||
|
|
let mut payload_key = [0u8; 32];
|
|||
|
|
hk.expand(b"payload", &mut payload_key)
|
|||
|
|
.map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?;
|
|||
|
|
Ok(payload_key)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add `pub mod decrypt;` and `pub use decrypt::Decryptor;` to `lib.rs`.
|
|||
|
|
|
|||
|
|
### 9.3 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets -- decrypt --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test decrypt::tests::decryptor_roundtrip_single_recipient ... ok
|
|||
|
|
test decrypt::tests::decryptor_roundtrip_multiple_recipients ... ok
|
|||
|
|
test decrypt::tests::decryptor_no_matching_identity_returns_no_match ... ok
|
|||
|
|
test decrypt::tests::decryptor_corrupted_mac_returns_mac_mismatch ... ok
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Run full suite to verify no regressions:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 9.4 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/decrypt.rs crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): Decryptor — identity-driven age decryption with MAC verification"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 10: sources/ migration — IdentitySource trait, update all three sources
|
|||
|
|
|
|||
|
|
- [ ] Add `IdentitySource` trait to `crates/notsecrets/src/sources/mod.rs`
|
|||
|
|
- [ ] Rewrite `sources/bitwarden.rs`
|
|||
|
|
- [ ] Rewrite `sources/file.rs`
|
|||
|
|
- [ ] Rewrite `sources/prompt.rs`
|
|||
|
|
- [ ] Remove `_legacy.rs` and all `#[deprecated]` shims from `lib.rs`
|
|||
|
|
|
|||
|
|
### 10.1 `crates/notsecrets/src/sources/mod.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::Identity;
|
|||
|
|
|
|||
|
|
pub mod bitwarden;
|
|||
|
|
pub mod file;
|
|||
|
|
pub mod prompt;
|
|||
|
|
|
|||
|
|
pub use bitwarden::BitwardenSource;
|
|||
|
|
pub use file::FileSource;
|
|||
|
|
pub use prompt::PromptSource;
|
|||
|
|
|
|||
|
|
/// Infra boundary trait: an identity resolver that loads key material from an
|
|||
|
|
/// external source and returns a concrete `Identity`.
|
|||
|
|
pub trait IdentitySource {
|
|||
|
|
fn name(&self) -> &str;
|
|||
|
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 10.2 Rewrite `sources/bitwarden.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::Identity;
|
|||
|
|
use crate::identities::x25519::X25519Identity;
|
|||
|
|
use crate::sources::IdentitySource;
|
|||
|
|
use std::process::Command;
|
|||
|
|
|
|||
|
|
pub struct BitwardenSource {
|
|||
|
|
pub item_name: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl BitwardenSource {
|
|||
|
|
pub fn new(item_name: impl Into<String>) -> Self {
|
|||
|
|
Self { item_name: item_name.into() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn retrieve_key(&self) -> Result<String, AgeError> {
|
|||
|
|
let bw_ok = std::process::Command::new("sh")
|
|||
|
|
.args(["-c", "command -v bw"])
|
|||
|
|
.status()
|
|||
|
|
.map(|s| s.success())
|
|||
|
|
.unwrap_or(false);
|
|||
|
|
if !bw_ok {
|
|||
|
|
return Err(AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("bw CLI not found in PATH"),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let session = std::env::var("BW_SESSION").unwrap_or_default();
|
|||
|
|
let session = if session.is_empty() {
|
|||
|
|
let password = rpassword::prompt_password("Bitwarden master password: ")
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("could not read password: {e}"),
|
|||
|
|
})?;
|
|||
|
|
let output = Command::new("bw")
|
|||
|
|
.args(["unlock", "--raw", &password])
|
|||
|
|
.output()
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("bw unlock spawn: {e}"),
|
|||
|
|
})?;
|
|||
|
|
if !output.status.success() {
|
|||
|
|
return Err(AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!(
|
|||
|
|
"bw unlock failed: {}",
|
|||
|
|
String::from_utf8_lossy(&output.stderr)
|
|||
|
|
),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
String::from_utf8(output.stdout)
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
|
|||
|
|
})?
|
|||
|
|
.trim()
|
|||
|
|
.to_string()
|
|||
|
|
} else {
|
|||
|
|
session
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let output = Command::new("bw")
|
|||
|
|
.args(["get", "notes", &self.item_name, "--session", &session])
|
|||
|
|
.output()
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("bw get spawn: {e}"),
|
|||
|
|
})?;
|
|||
|
|
|
|||
|
|
if !output.status.success() {
|
|||
|
|
return Err(AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!(
|
|||
|
|
"bw get notes '{}' failed: {}",
|
|||
|
|
self.item_name,
|
|||
|
|
String::from_utf8_lossy(&output.stderr)
|
|||
|
|
),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let key = String::from_utf8(output.stdout)
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("bw output UTF-8: {e}"),
|
|||
|
|
})?
|
|||
|
|
.trim()
|
|||
|
|
.to_string();
|
|||
|
|
|
|||
|
|
if key.is_empty() {
|
|||
|
|
return Err(AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
Ok(key)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl IdentitySource for BitwardenSource {
|
|||
|
|
fn name(&self) -> &str {
|
|||
|
|
"bitwarden"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
|||
|
|
let key = self.retrieve_key()?;
|
|||
|
|
let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| {
|
|||
|
|
AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
|||
|
|
}
|
|||
|
|
})?;
|
|||
|
|
Ok(Box::new(identity))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 10.3 Rewrite `sources/file.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::Identity;
|
|||
|
|
use crate::identities::x25519::X25519Identity;
|
|||
|
|
use crate::identities::encrypted::EncryptedIdentity;
|
|||
|
|
use crate::identities::scrypt::ScryptIdentity;
|
|||
|
|
use crate::sources::IdentitySource;
|
|||
|
|
use std::path::PathBuf;
|
|||
|
|
|
|||
|
|
const AGE_VERSION_LINE: &str = "age-encryption.org/v1";
|
|||
|
|
|
|||
|
|
pub struct FileSource {
|
|||
|
|
path: PathBuf,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl FileSource {
|
|||
|
|
pub fn new(path: PathBuf) -> Self {
|
|||
|
|
Self { path }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl IdentitySource for FileSource {
|
|||
|
|
fn name(&self) -> &str {
|
|||
|
|
"file"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
|||
|
|
let content = std::fs::read(&self.path).map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()),
|
|||
|
|
})?;
|
|||
|
|
|
|||
|
|
// Detect identity type from file content
|
|||
|
|
if content.starts_with(b"AGE-SECRET-KEY-1") {
|
|||
|
|
let key_str = std::str::from_utf8(&content)
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("key file UTF-8: {e}"),
|
|||
|
|
})?
|
|||
|
|
.trim();
|
|||
|
|
let identity = X25519Identity::from_bech32(key_str).map_err(|e| {
|
|||
|
|
AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
|
|||
|
|
}
|
|||
|
|
})?;
|
|||
|
|
Ok(Box::new(identity))
|
|||
|
|
} else if content.starts_with(AGE_VERSION_LINE.as_bytes()) {
|
|||
|
|
// age-encrypted identity file — prompt for passphrase to decrypt
|
|||
|
|
let passphrase = rpassword::prompt_password(
|
|||
|
|
"Enter passphrase for encrypted identity file: ",
|
|||
|
|
)
|
|||
|
|
.map_err(|e| AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
|||
|
|
})?;
|
|||
|
|
let passphrase_identity = ScryptIdentity::new(passphrase);
|
|||
|
|
Ok(Box::new(EncryptedIdentity::new(content, Box::new(passphrase_identity))))
|
|||
|
|
} else {
|
|||
|
|
Err(AgeError::UnsupportedKeyType(format!(
|
|||
|
|
"file {} does not contain a recognized age key format",
|
|||
|
|
self.path.display()
|
|||
|
|
)))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 10.4 Rewrite `sources/prompt.rs`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use crate::error::AgeError;
|
|||
|
|
use crate::identities::Identity;
|
|||
|
|
use crate::identities::scrypt::ScryptIdentity;
|
|||
|
|
use crate::sources::IdentitySource;
|
|||
|
|
|
|||
|
|
pub struct PromptSource;
|
|||
|
|
|
|||
|
|
impl IdentitySource for PromptSource {
|
|||
|
|
fn name(&self) -> &str {
|
|||
|
|
"prompt"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
|||
|
|
let passphrase = rpassword::prompt_password("Enter age passphrase: ").map_err(|e| {
|
|||
|
|
AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
|||
|
|
}
|
|||
|
|
})?;
|
|||
|
|
if passphrase.trim().is_empty() {
|
|||
|
|
return Err(AgeError::SourceError {
|
|||
|
|
name: self.name().to_string(),
|
|||
|
|
source: anyhow::anyhow!("empty passphrase entered"),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
Ok(Box::new(ScryptIdentity::new(passphrase)))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 10.5 Remove legacy shims
|
|||
|
|
|
|||
|
|
Delete `crates/notsecrets/src/_legacy.rs`. Remove the `mod _legacy` and `pub use _legacy::...` lines from `lib.rs`. The `notstrap` crate will be updated in Task 12.
|
|||
|
|
|
|||
|
|
### 10.6 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: all tests pass.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 10.7 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/sources/ crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): migrate sources to IdentitySource trait, remove legacy API"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 11: resolve_identities() public API
|
|||
|
|
|
|||
|
|
- [ ] Add `resolve_identities` to `lib.rs`
|
|||
|
|
|
|||
|
|
### 11.1 Implementation
|
|||
|
|
|
|||
|
|
In `crates/notsecrets/src/lib.rs`, add:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
|||
|
|
///
|
|||
|
|
/// If all sources fail, returns the last `AgeError::SourceError`. If at least one
|
|||
|
|
/// source succeeds, returns the list of loaded identities (partial success is accepted
|
|||
|
|
/// because not every source needs to hold the key for the target file).
|
|||
|
|
pub fn resolve_identities(
|
|||
|
|
sources: Vec<Box<dyn sources::IdentitySource>>,
|
|||
|
|
) -> Result<Vec<Box<dyn Identity>>, AgeError> {
|
|||
|
|
let mut identities: Vec<Box<dyn Identity>> = Vec::new();
|
|||
|
|
let mut last_err: Option<AgeError> = None;
|
|||
|
|
|
|||
|
|
for source in sources {
|
|||
|
|
match source.load() {
|
|||
|
|
Ok(identity) => identities.push(identity),
|
|||
|
|
Err(e) => {
|
|||
|
|
eprintln!(" [{}] {e}", source.name());
|
|||
|
|
last_err = Some(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if identities.is_empty() {
|
|||
|
|
Err(last_err.unwrap_or(AgeError::SourceError {
|
|||
|
|
name: "resolve_identities".to_string(),
|
|||
|
|
source: anyhow::anyhow!("no sources provided"),
|
|||
|
|
}))
|
|||
|
|
} else {
|
|||
|
|
Ok(identities)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 11.2 Test
|
|||
|
|
|
|||
|
|
Add to `lib.rs` test module:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::sources::IdentitySource;
|
|||
|
|
|
|||
|
|
struct AlwaysFailSource;
|
|||
|
|
impl IdentitySource for AlwaysFailSource {
|
|||
|
|
fn name(&self) -> &str { "always-fail" }
|
|||
|
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
|||
|
|
Err(AgeError::SourceError {
|
|||
|
|
name: "always-fail".to_string(),
|
|||
|
|
source: anyhow::anyhow!("intentional failure"),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
struct StaticX25519Source {
|
|||
|
|
identity: crate::identities::x25519::X25519Identity,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl StaticX25519Source {
|
|||
|
|
fn new() -> Self {
|
|||
|
|
use x25519_dalek::StaticSecret;
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
Self {
|
|||
|
|
identity: crate::identities::x25519::X25519Identity::from_static_secret(
|
|||
|
|
StaticSecret::random_from_rng(OsRng),
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl IdentitySource for StaticX25519Source {
|
|||
|
|
fn name(&self) -> &str { "static-x25519" }
|
|||
|
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
|||
|
|
use x25519_dalek::StaticSecret;
|
|||
|
|
use rand::rngs::OsRng;
|
|||
|
|
Ok(Box::new(
|
|||
|
|
crate::identities::x25519::X25519Identity::from_static_secret(
|
|||
|
|
StaticSecret::random_from_rng(OsRng),
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn resolve_identities_all_fail_returns_error() {
|
|||
|
|
let sources: Vec<Box<dyn IdentitySource>> = vec![Box::new(AlwaysFailSource)];
|
|||
|
|
assert!(resolve_identities(sources).is_err());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn resolve_identities_partial_success_returns_loaded() {
|
|||
|
|
let sources: Vec<Box<dyn IdentitySource>> = vec![
|
|||
|
|
Box::new(AlwaysFailSource),
|
|||
|
|
Box::new(StaticX25519Source::new()),
|
|||
|
|
];
|
|||
|
|
let identities = resolve_identities(sources).expect("at least one source succeeded");
|
|||
|
|
assert_eq!(identities.len(), 1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 11.3 Run tests
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test -p notsecrets --nocapture
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: all pass.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy -p notsecrets -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 11.4 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notsecrets/src/lib.rs
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): add resolve_identities() public API"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 12: notstrap migration
|
|||
|
|
|
|||
|
|
Update `crates/notstrap/src/lib.rs` to replace the old `notsecrets` API with the new one.
|
|||
|
|
|
|||
|
|
- [ ] Update `crates/notstrap/src/lib.rs` — two call sites
|
|||
|
|
- [ ] Update `crates/notstrap/Cargo.toml` if needed
|
|||
|
|
- [ ] Run `cargo check --workspace`
|
|||
|
|
- [ ] Run `cargo test --workspace`
|
|||
|
|
|
|||
|
|
### 12.1 Changes to `crates/notstrap/src/lib.rs`
|
|||
|
|
|
|||
|
|
**Import line** (line 5), replace:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use notsecrets::{BitwardenSource, FileSource, PromptSource, install_age_key, resolve_age_key};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
with:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
use notsecrets::{
|
|||
|
|
BitwardenSource, Decryptor, FileSource, PromptSource,
|
|||
|
|
resolve_identities,
|
|||
|
|
sources::IdentitySource,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4 block** (lines 92–110), replace:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
// 4. Retrieve age key and install
|
|||
|
|
let sources: Vec<Box<dyn notsecrets::AgeKeySource>> = if let Some(kf) = opts.key_file {
|
|||
|
|
vec![Box::new(FileSource::new(kf))]
|
|||
|
|
} else {
|
|||
|
|
vec![
|
|||
|
|
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
|
|||
|
|
Box::new(PromptSource),
|
|||
|
|
]
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
match resolve_age_key(sources) {
|
|||
|
|
Ok(key) => {
|
|||
|
|
install_age_key(&key)?;
|
|||
|
|
report.add("age key", StepStatus::Ok);
|
|||
|
|
}
|
|||
|
|
Err(e) => {
|
|||
|
|
report.add("age key", StepStatus::Failed(e.to_string()));
|
|||
|
|
return Ok(report);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
with:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
// 4. Resolve age identities
|
|||
|
|
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
|
|||
|
|
vec![Box::new(FileSource::new(kf))]
|
|||
|
|
} else {
|
|||
|
|
vec![
|
|||
|
|
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
|
|||
|
|
Box::new(PromptSource),
|
|||
|
|
]
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let identities = match resolve_identities(sources) {
|
|||
|
|
Ok(ids) => {
|
|||
|
|
report.add("age key", StepStatus::Ok);
|
|||
|
|
ids
|
|||
|
|
}
|
|||
|
|
Err(e) => {
|
|||
|
|
report.add("age key", StepStatus::Failed(e.to_string()));
|
|||
|
|
return Ok(report);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**env_injector closure** in `BootstrapOptions` and its call site (lines 112–142):
|
|||
|
|
|
|||
|
|
The `env_injector` closure currently captures a `sops` call. Replace it so callers pass a closure that captures a `Decryptor`. Update the type alias to remain the same (`Box<dyn Fn(&Path) -> Result<String>>`) — this keeps the blast radius minimal. The `notstrap` binary's `main.rs` builds the `env_injector` closure; update it there:
|
|||
|
|
|
|||
|
|
In `crates/notstrap/src/main.rs`, the env_injector construction (wherever `decrypt_sops` was called) becomes:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
let env_injector: Option<notstrap::EnvInjector> = Some(Box::new({
|
|||
|
|
let identities = identities.clone(); // requires Identity: Clone, or Arc wrapping
|
|||
|
|
move |path: &std::path::Path| {
|
|||
|
|
let bytes = std::fs::read(path)
|
|||
|
|
.with_context(|| format!("cannot read sops file {}", path.display()))?;
|
|||
|
|
let decryptor = notsecrets::Decryptor::with_identities(identities);
|
|||
|
|
let plaintext = decryptor.decrypt(&bytes)
|
|||
|
|
.map_err(|e| anyhow::anyhow!("age decrypt failed: {e}"))?;
|
|||
|
|
String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("plaintext UTF-8: {e}"))
|
|||
|
|
}
|
|||
|
|
}));
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For `Box<dyn Identity>` to be cloneable across the closure boundary, wrap identities in `Arc`:
|
|||
|
|
|
|||
|
|
Change `identities` type from `Vec<Box<dyn Identity>>` to `Vec<Arc<dyn Identity>>` in the closure. Adjust `Decryptor::with_identities` to accept `Vec<Arc<dyn Identity>>` as an alternative, or convert: `identities.into_iter().map(|i| i as Box<dyn Identity>).collect()`.
|
|||
|
|
|
|||
|
|
The simplest approach: keep `Decryptor::with_identities(Vec<Box<dyn Identity>>)` and pass ownership into the closure — the closure is `FnOnce`, and `EnvInjector` is `Box<dyn Fn>` which requires `Fn`. Resolve by storing identities in `Arc<Vec<Box<dyn Identity>>>`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
// In lib.rs: expose a Decryptor constructor from Arc
|
|||
|
|
impl Decryptor {
|
|||
|
|
pub fn with_identities_arc(identities: std::sync::Arc<Vec<Box<dyn Identity>>>) -> Self {
|
|||
|
|
// store Arc internally
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Or simplest of all: change `EnvInjector` from `Box<dyn Fn>` to `Box<dyn FnOnce>` in `BootstrapOptions` since it is only ever called once. This requires changing one field and one call site in `lib.rs`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
type EnvInjector = Box<dyn FnOnce(&Path) -> Result<String>>;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
and in `run()`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
if let Some(injector) = opts.env_injector {
|
|||
|
|
let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file);
|
|||
|
|
match injector(&sops_path) { ... }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`FnOnce` is called the same way via `injector(arg)`. This change is fully backwards-compatible with existing test code.
|
|||
|
|
|
|||
|
|
### 12.2 Remove legacy notsecrets exports
|
|||
|
|
|
|||
|
|
Verify `crates/notstrap/Cargo.toml` does not need changes (it already depends on `notsecrets = { workspace = true }`).
|
|||
|
|
|
|||
|
|
Check workspace `Cargo.toml`: remove `which` from `[workspace.dependencies]` if no other crate references it:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo metadata --no-deps --format-version 1 | python3 -c "import json,sys; d=json.load(sys.stdin); [print(p['name'],p.get('dependencies',[])) for p in d['packages']]" 2>/dev/null | grep which
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
If only `notsecrets` used it, remove from workspace.
|
|||
|
|
|
|||
|
|
### 12.3 Verify
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo check --workspace
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 errors.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo test --workspace --nocapture 2>&1 | tail -40
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: all tests pass.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cargo clippy --workspace -- -D warnings
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 0 warnings.
|
|||
|
|
|
|||
|
|
### 12.4 Commit
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git -C /Users/joe/dev/notfiles add crates/notstrap/src/ Cargo.toml
|
|||
|
|
git -C /Users/joe/dev/notfiles commit -m "feat(notstrap): migrate to notsecrets age-native API, remove sops shell-out"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Self-Review Checklist
|
|||
|
|
|
|||
|
|
Before considering the plan complete, verify:
|
|||
|
|
|
|||
|
|
1. **All spec features covered:**
|
|||
|
|
- [x] Multiple recipients — Task 8 (`Encryptor::with_recipients`)
|
|||
|
|
- [x] Passphrases (scrypt) — Task 4
|
|||
|
|
- [x] Passphrase-protected identity files — Task 7 (`EncryptedIdentity`)
|
|||
|
|
- [x] SSH Ed25519 — Task 5
|
|||
|
|
- [x] SSH RSA — Task 6
|
|||
|
|
- [x] X25519 identity + recipient — Task 3
|
|||
|
|
- [x] age wire format parser/serializer — Task 2
|
|||
|
|
- [x] Header MAC verification — Task 9 (`Decryptor`)
|
|||
|
|
- [x] `resolve_identities` — Task 11
|
|||
|
|
- [x] Sources migration — Task 10
|
|||
|
|
- [x] notstrap migration — Task 12
|
|||
|
|
|
|||
|
|
2. **No placeholder text:** Every code block is complete. No "TBD", ellipsis substitutions, or "implement X appropriately".
|
|||
|
|
|
|||
|
|
3. **Type names consistent across tasks:**
|
|||
|
|
- `FileKey`, `Stanza`, `Header` defined in Task 1, used throughout
|
|||
|
|
- `Identity`, `Recipient`, `IdentitySource` traits defined in Tasks 1 and 10
|
|||
|
|
- `AgeError` defined in Task 1, referenced in all subsequent tasks
|
|||
|
|
- `Encryptor` defined in Task 8, referenced in Task 7 test and Task 12
|
|||
|
|
- `Decryptor` defined in Task 9, referenced in Task 7 impl and Task 12
|
|||
|
|
|
|||
|
|
4. **TDD discipline:** Every task writes the failing test first, specifies the expected failure output, then provides the implementation.
|
|||
|
|
|
|||
|
|
5. **`cargo clippy -p notsecrets -- -D warnings` called after every task.**
|
|||
|
|
|
|||
|
|
6. **Every file path is exact** — all paths use `crates/notsecrets/src/...` or `crates/notstrap/src/...` with no ambiguity.
|