docs: add comprehensive doc comments to all Rust source files
Document every public function, struct, field, constant, and non-trivial private function across idfoto-core and idfoto-cli. Module-level docs explain each module's role in the architecture. Comments explain the "why" (crypto choices, algorithm design, data model rationale) not just the "what". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,42 @@
|
|||||||
|
//! idfoto CLI -- the platform layer for the idfoto password manager.
|
||||||
|
//!
|
||||||
|
//! This binary provides the filesystem, git, and terminal I/O that
|
||||||
|
//! [`idfoto_core`] intentionally excludes. It is the "glue" between the
|
||||||
|
//! platform-agnostic core library and the user's local environment.
|
||||||
|
//!
|
||||||
|
//! ## Vault layout on disk
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <vault_dir>/
|
||||||
|
//! .idfoto/
|
||||||
|
//! salt # 32-byte random salt for Argon2id KDF
|
||||||
|
//! params.json # KDF tuning parameters (m, t, p)
|
||||||
|
//! devices.json # registered device public keys
|
||||||
|
//! entries/
|
||||||
|
//! <id>.enc # individual encrypted entries
|
||||||
|
//! manifest.enc # encrypted entry index (name, url, username per entry)
|
||||||
|
//! .gitignore # excludes reference.jpg from version control
|
||||||
|
//! reference.jpg # the reference image with embedded secret (gitignored)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Unlock flow
|
||||||
|
//!
|
||||||
|
//! Every command that accesses vault data follows this sequence:
|
||||||
|
//!
|
||||||
|
//! 1. Locate the reference image (via `IDFOTO_IMAGE` env var or interactive prompt).
|
||||||
|
//! 2. Prompt for the passphrase (read from stderr, not echoed).
|
||||||
|
//! 3. Extract the 32-byte image secret from the reference JPEG via DCT steganography.
|
||||||
|
//! 4. Read the vault salt and KDF params from `.idfoto/`.
|
||||||
|
//! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`.
|
||||||
|
//! 6. Use the master key to decrypt the manifest and/or individual entries.
|
||||||
|
//!
|
||||||
|
//! ## Git integration
|
||||||
|
//!
|
||||||
|
//! The CLI shells out to the `git` binary for all version control operations.
|
||||||
|
//! This avoids pulling in libgit2 or gitoxide as dependencies, keeping the
|
||||||
|
//! binary small and the build simple. Every mutation (add, edit, rm, device add/revoke)
|
||||||
|
//! creates a git commit, preserving an audit log of all vault changes.
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use idfoto_core::{
|
use idfoto_core::{
|
||||||
@@ -14,6 +53,7 @@ use std::process::Command;
|
|||||||
|
|
||||||
// ─── CLI structure ──────────────────────────────────────────────────────────
|
// ─── CLI structure ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Top-level CLI argument parser.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "idfoto",
|
name = "idfoto",
|
||||||
@@ -25,70 +65,105 @@ struct Cli {
|
|||||||
command: Commands,
|
command: Commands,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All available CLI subcommands.
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Initialize a new idfoto vault
|
/// Initialize a new idfoto vault in the current directory.
|
||||||
|
/// Creates the directory structure, generates a random image secret,
|
||||||
|
/// embeds it in the carrier image, and sets up git.
|
||||||
Init {
|
Init {
|
||||||
|
/// Path to the carrier JPEG image to embed the secret into.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
image: PathBuf,
|
image: PathBuf,
|
||||||
|
/// Output path for the reference image (with embedded secret).
|
||||||
#[arg(long, default_value = "reference.jpg")]
|
#[arg(long, default_value = "reference.jpg")]
|
||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
},
|
},
|
||||||
/// Add a new password entry
|
/// Add a new password entry to the vault.
|
||||||
|
/// Prompts interactively for name, URL, username, password, notes, and TOTP.
|
||||||
Add,
|
Add,
|
||||||
/// Get a password entry by name
|
/// Get a password entry by name (fuzzy search).
|
||||||
|
/// Decrypts and displays the full entry, and copies the password to clipboard
|
||||||
|
/// with a 30-second auto-clear.
|
||||||
Get { name: String },
|
Get { name: String },
|
||||||
/// List all entries
|
/// List all entries in the vault (names, URLs, usernames only -- no passwords).
|
||||||
List,
|
List,
|
||||||
/// Edit an existing entry
|
/// Edit an existing entry by name (fuzzy search).
|
||||||
|
/// Shows current values and lets you selectively update fields.
|
||||||
Edit { name: String },
|
Edit { name: String },
|
||||||
/// Remove an entry
|
/// Remove an entry from the vault by name (fuzzy search).
|
||||||
|
/// Prompts for confirmation before deleting.
|
||||||
Rm { name: String },
|
Rm { name: String },
|
||||||
/// Sync vault with git remote
|
/// Sync the vault with the git remote (pull --rebase, then push).
|
||||||
Sync,
|
Sync,
|
||||||
/// Generate a random password
|
/// Generate a random password and print it to stdout.
|
||||||
Generate {
|
Generate {
|
||||||
|
/// Length of the generated password in characters.
|
||||||
#[arg(short, long, default_value = "20")]
|
#[arg(short, long, default_value = "20")]
|
||||||
length: usize,
|
length: usize,
|
||||||
},
|
},
|
||||||
/// Manage devices
|
/// Manage device keys (add, list, revoke).
|
||||||
|
/// Device ed25519 keys are independent of the vault KDF -- revoking a device
|
||||||
|
/// does not require changing the passphrase or reference image.
|
||||||
Device {
|
Device {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: DeviceCommands,
|
action: DeviceCommands,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subcommands for device key management.
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum DeviceCommands {
|
enum DeviceCommands {
|
||||||
/// Add a new device
|
/// Register a new device by generating an ed25519 keypair.
|
||||||
|
/// The private key is saved to the user's config directory;
|
||||||
|
/// the public key is added to the vault's devices.json.
|
||||||
Add {
|
Add {
|
||||||
|
/// Human-readable name for this device (e.g., "macbook", "phone").
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
/// List registered devices
|
/// List all registered devices and their public keys.
|
||||||
List,
|
List,
|
||||||
/// Revoke a device
|
/// Revoke a device by removing its public key from devices.json.
|
||||||
|
/// This does NOT rotate the vault key -- the device can no longer
|
||||||
|
/// authenticate, but the vault encryption is unchanged.
|
||||||
Revoke { name: String },
|
Revoke { name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Device entry ───────────────────────────────────────────────────────────
|
// ─── Device entry ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A registered device, stored in `.idfoto/devices.json`.
|
||||||
|
///
|
||||||
|
/// Each device has an ed25519 keypair. The private key lives on the device
|
||||||
|
/// itself (in the user's config directory); only the public key is stored
|
||||||
|
/// in the vault. This separation means revoking a device is a metadata-only
|
||||||
|
/// operation that does not affect the vault's encryption key.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct DeviceEntry {
|
struct DeviceEntry {
|
||||||
|
/// Human-readable device name (e.g., "macbook-pro", "pixel-7").
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Hex-encoded ed25519 public key (64 hex chars = 32 bytes).
|
||||||
public_key: String, // hex-encoded
|
public_key: String, // hex-encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper functions ───────────────────────────────────────────────────────
|
// ─── Helper functions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns the vault root directory (the current working directory).
|
||||||
|
/// The vault is always rooted at the directory where `idfoto` is invoked.
|
||||||
fn vault_dir() -> PathBuf {
|
fn vault_dir() -> PathBuf {
|
||||||
std::env::current_dir().expect("failed to get current directory")
|
std::env::current_dir().expect("failed to get current directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the `.idfoto/` configuration directory within the vault.
|
||||||
fn idfoto_dir() -> PathBuf {
|
fn idfoto_dir() -> PathBuf {
|
||||||
vault_dir().join(".idfoto")
|
vault_dir().join(".idfoto")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the 32-byte vault salt from `.idfoto/salt`.
|
||||||
|
///
|
||||||
|
/// The salt is generated once during `init` and is unique per vault. It is
|
||||||
|
/// not secret (stored in plaintext) -- its purpose is to prevent precomputed
|
||||||
|
/// rainbow table attacks against the Argon2id KDF.
|
||||||
fn read_salt() -> Result<[u8; 32]> {
|
fn read_salt() -> Result<[u8; 32]> {
|
||||||
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
|
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
|
||||||
let mut salt = [0u8; 32];
|
let mut salt = [0u8; 32];
|
||||||
@@ -99,6 +174,7 @@ fn read_salt() -> Result<[u8; 32]> {
|
|||||||
Ok(salt)
|
Ok(salt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the KDF parameters from `.idfoto/params.json`.
|
||||||
fn read_params() -> Result<KdfParams> {
|
fn read_params() -> Result<KdfParams> {
|
||||||
let data = fs::read_to_string(idfoto_dir().join("params.json"))
|
let data = fs::read_to_string(idfoto_dir().join("params.json"))
|
||||||
.context("failed to read params.json")?;
|
.context("failed to read params.json")?;
|
||||||
@@ -106,6 +182,10 @@ fn read_params() -> Result<KdfParams> {
|
|||||||
Ok(params)
|
Ok(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Locate the reference image path.
|
||||||
|
///
|
||||||
|
/// First checks the `IDFOTO_IMAGE` environment variable (useful for scripting
|
||||||
|
/// and testing). If not set, prompts the user interactively.
|
||||||
fn get_image_path() -> Result<PathBuf> {
|
fn get_image_path() -> Result<PathBuf> {
|
||||||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
||||||
return Ok(PathBuf::from(path));
|
return Ok(PathBuf::from(path));
|
||||||
@@ -114,6 +194,13 @@ fn get_image_path() -> Result<PathBuf> {
|
|||||||
Ok(PathBuf::from(path))
|
Ok(PathBuf::from(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform the two-factor unlock sequence and return the derived master key.
|
||||||
|
///
|
||||||
|
/// This is the core authentication flow used by every vault-access command:
|
||||||
|
/// 1. Prompt for the passphrase (via rpassword, not echoed to terminal).
|
||||||
|
/// 2. Read and decode the reference JPEG, extracting the steganographic secret.
|
||||||
|
/// 3. Load the vault salt and KDF params.
|
||||||
|
/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt).
|
||||||
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
||||||
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
|
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
|
||||||
|
|
||||||
@@ -130,18 +217,25 @@ fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
|||||||
Ok(master_key)
|
Ok(master_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt and return the vault manifest.
|
||||||
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
|
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
|
||||||
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
|
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
|
||||||
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
|
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
|
||||||
Ok(manifest)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encrypt and write the vault manifest to disk.
|
||||||
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
||||||
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
|
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
|
||||||
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
|
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage all changes and create a git commit with the given message.
|
||||||
|
///
|
||||||
|
/// Every vault mutation is committed to preserve a full audit log in git history.
|
||||||
|
/// The CLI shells out to the `git` binary rather than using a Rust git library
|
||||||
|
/// to keep dependencies minimal.
|
||||||
fn git_commit(message: &str) -> Result<()> {
|
fn git_commit(message: &str) -> Result<()> {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.args(["add", "-A"])
|
.args(["add", "-A"])
|
||||||
@@ -162,6 +256,10 @@ fn git_commit(message: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the current time as a Unix timestamp string.
|
||||||
|
///
|
||||||
|
/// Uses seconds since epoch rather than a formatted ISO 8601 string to avoid
|
||||||
|
/// pulling in chrono or time crate dependencies.
|
||||||
fn now_iso8601() -> String {
|
fn now_iso8601() -> String {
|
||||||
let duration = std::time::SystemTime::now()
|
let duration = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -169,6 +267,7 @@ fn now_iso8601() -> String {
|
|||||||
format!("{}", duration.as_secs())
|
format!("{}", duration.as_secs())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prompt the user for input via stderr (so stdout remains clean for piping).
|
||||||
fn prompt(message: &str) -> Result<String> {
|
fn prompt(message: &str) -> Result<String> {
|
||||||
eprint!("{}: ", message);
|
eprint!("{}: ", message);
|
||||||
io::stderr().flush()?;
|
io::stderr().flush()?;
|
||||||
@@ -177,6 +276,7 @@ fn prompt(message: &str) -> Result<String> {
|
|||||||
Ok(line.trim().to_string())
|
Ok(line.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prompt for an optional field. Returns `None` if the user enters an empty string.
|
||||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||||
let value = prompt(message)?;
|
let value = prompt(message)?;
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
@@ -186,6 +286,8 @@ fn prompt_optional(message: &str) -> Result<Option<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prompt for a field with a default value shown in brackets.
|
||||||
|
/// If the user presses Enter without typing, the current value is kept.
|
||||||
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
||||||
eprint!("{} [{}]: ", field, current);
|
eprint!("{} [{}]: ", field, current);
|
||||||
io::stderr().flush()?;
|
io::stderr().flush()?;
|
||||||
@@ -199,6 +301,10 @@ fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a random password of the given length using a mixed character set.
|
||||||
|
///
|
||||||
|
/// The charset includes lowercase, uppercase, digits, and common symbols.
|
||||||
|
/// Each character is selected uniformly at random via the OS CSPRNG.
|
||||||
fn generate_password(length: usize) -> String {
|
fn generate_password(length: usize) -> String {
|
||||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
||||||
let mut rng = OsRng;
|
let mut rng = OsRng;
|
||||||
@@ -212,6 +318,19 @@ fn generate_password(length: usize) -> String {
|
|||||||
|
|
||||||
// ─── Command implementations ────────────────────────────────────────────────
|
// ─── Command implementations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Initialize a new idfoto vault in the current directory.
|
||||||
|
///
|
||||||
|
/// Full sequence:
|
||||||
|
/// 1. Read the carrier JPEG provided by the user.
|
||||||
|
/// 2. Generate a random 32-byte image secret.
|
||||||
|
/// 3. Embed the secret into the carrier via DCT steganography.
|
||||||
|
/// 4. Save the resulting reference JPEG (this is the user's second factor).
|
||||||
|
/// 5. Prompt for a passphrase (minimum 8 characters, with confirmation).
|
||||||
|
/// 6. Generate a random 32-byte salt.
|
||||||
|
/// 7. Derive the master key from passphrase + image_secret + salt.
|
||||||
|
/// 8. Create the vault directory structure (.idfoto/, entries/).
|
||||||
|
/// 9. Write salt, KDF params, empty devices list, and encrypted empty manifest.
|
||||||
|
/// 10. Initialize git and create the first commit.
|
||||||
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||||
// 1. Read carrier JPEG
|
// 1. Read carrier JPEG
|
||||||
let carrier = fs::read(&image).context("failed to read carrier image")?;
|
let carrier = fs::read(&image).context("failed to read carrier image")?;
|
||||||
@@ -274,7 +393,8 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
|
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
|
||||||
.context("failed to write manifest.enc")?;
|
.context("failed to write manifest.enc")?;
|
||||||
|
|
||||||
// 11. Create .gitignore
|
// 11. Create .gitignore (exclude reference image from version control --
|
||||||
|
// it contains the steganographic secret and must be kept offline)
|
||||||
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
|
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
|
||||||
.context("failed to write .gitignore")?;
|
.context("failed to write .gitignore")?;
|
||||||
|
|
||||||
@@ -292,11 +412,16 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a random password and print it to stdout.
|
||||||
fn cmd_generate(length: usize) -> Result<()> {
|
fn cmd_generate(length: usize) -> Result<()> {
|
||||||
println!("{}", generate_password(length));
|
println!("{}", generate_password(length));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a new entry to the vault.
|
||||||
|
///
|
||||||
|
/// Prompts for all fields, encrypts the entry, writes it to `entries/<id>.enc`,
|
||||||
|
/// updates the manifest, and commits the change to git.
|
||||||
fn cmd_add() -> Result<()> {
|
fn cmd_add() -> Result<()> {
|
||||||
let image_path = get_image_path()?;
|
let image_path = get_image_path()?;
|
||||||
let master_key = unlock(&image_path)?;
|
let master_key = unlock(&image_path)?;
|
||||||
@@ -362,6 +487,10 @@ fn cmd_add() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search the manifest for entries matching a query and let the user select one.
|
||||||
|
///
|
||||||
|
/// If exactly one entry matches, it is returned immediately. If multiple match,
|
||||||
|
/// the user is shown a numbered list and prompted to choose.
|
||||||
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
|
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
|
||||||
let results = manifest.search(query);
|
let results = manifest.search(query);
|
||||||
if results.is_empty() {
|
if results.is_empty() {
|
||||||
@@ -394,6 +523,11 @@ fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, Manife
|
|||||||
Ok((id.clone(), entry.clone()))
|
Ok((id.clone(), entry.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieve and display a vault entry, and copy its password to the clipboard.
|
||||||
|
///
|
||||||
|
/// The password is auto-cleared from the clipboard after 30 seconds to limit
|
||||||
|
/// exposure. The clipboard clear is best-effort (a background thread checks
|
||||||
|
/// whether the clipboard still contains the password before clearing).
|
||||||
fn cmd_get(query: String) -> Result<()> {
|
fn cmd_get(query: String) -> Result<()> {
|
||||||
let image_path = get_image_path()?;
|
let image_path = get_image_path()?;
|
||||||
let master_key = unlock(&image_path)?;
|
let master_key = unlock(&image_path)?;
|
||||||
@@ -422,7 +556,10 @@ fn cmd_get(query: String) -> Result<()> {
|
|||||||
println!("TOTP: {}", totp);
|
println!("TOTP: {}", totp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy password to clipboard with 30s TTL
|
// Copy password to clipboard with 30s TTL.
|
||||||
|
// Uses arboard for cross-platform clipboard access.
|
||||||
|
// The clear is done in a background thread: after 30 seconds, if the
|
||||||
|
// clipboard still contains this password, it is replaced with an empty string.
|
||||||
match arboard::Clipboard::new() {
|
match arboard::Clipboard::new() {
|
||||||
Ok(mut clipboard) => {
|
Ok(mut clipboard) => {
|
||||||
if clipboard.set_text(&entry.password).is_ok() {
|
if clipboard.set_text(&entry.password).is_ok() {
|
||||||
@@ -448,6 +585,10 @@ fn cmd_get(query: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all vault entries in alphabetical order.
|
||||||
|
///
|
||||||
|
/// Only shows non-sensitive metadata (name, URL, username) from the manifest.
|
||||||
|
/// Individual entry files are not decrypted.
|
||||||
fn cmd_list() -> Result<()> {
|
fn cmd_list() -> Result<()> {
|
||||||
let image_path = get_image_path()?;
|
let image_path = get_image_path()?;
|
||||||
let master_key = unlock(&image_path)?;
|
let master_key = unlock(&image_path)?;
|
||||||
@@ -477,6 +618,8 @@ fn cmd_list() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Edit an existing entry by searching for it, showing current values, and
|
||||||
|
/// prompting for new values. Unchanged fields keep their current value.
|
||||||
fn cmd_edit(query: String) -> Result<()> {
|
fn cmd_edit(query: String) -> Result<()> {
|
||||||
let image_path = get_image_path()?;
|
let image_path = get_image_path()?;
|
||||||
let master_key = unlock(&image_path)?;
|
let master_key = unlock(&image_path)?;
|
||||||
@@ -546,6 +689,10 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove an entry from the vault after confirmation.
|
||||||
|
///
|
||||||
|
/// Deletes the encrypted entry file, removes the entry from the manifest,
|
||||||
|
/// and commits the change to git.
|
||||||
fn cmd_rm(query: String) -> Result<()> {
|
fn cmd_rm(query: String) -> Result<()> {
|
||||||
let image_path = get_image_path()?;
|
let image_path = get_image_path()?;
|
||||||
let master_key = unlock(&image_path)?;
|
let master_key = unlock(&image_path)?;
|
||||||
@@ -576,6 +723,11 @@ fn cmd_rm(query: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync the vault with the git remote.
|
||||||
|
///
|
||||||
|
/// Performs `git pull --rebase` followed by `git push`. Rebase is used instead
|
||||||
|
/// of merge to keep the commit history linear, which is important for the
|
||||||
|
/// audit log use case.
|
||||||
fn cmd_sync() -> Result<()> {
|
fn cmd_sync() -> Result<()> {
|
||||||
eprintln!("Pulling...");
|
eprintln!("Pulling...");
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
@@ -601,6 +753,7 @@ fn cmd_sync() -> Result<()> {
|
|||||||
|
|
||||||
// ─── Device management ──────────────────────────────────────────────────────
|
// ─── Device management ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read the device registry from `.idfoto/devices.json`.
|
||||||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||||||
let path = idfoto_dir().join("devices.json");
|
let path = idfoto_dir().join("devices.json");
|
||||||
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
|
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
|
||||||
@@ -608,30 +761,39 @@ fn read_devices() -> Result<Vec<DeviceEntry>> {
|
|||||||
Ok(devices)
|
Ok(devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the device registry to `.idfoto/devices.json`.
|
||||||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
||||||
let data = serde_json::to_string_pretty(devices)?;
|
let data = serde_json::to_string_pretty(devices)?;
|
||||||
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
|
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a new device by generating an ed25519 keypair.
|
||||||
|
///
|
||||||
|
/// The private key is saved to `~/.config/idfoto/<name>.key` with
|
||||||
|
/// restrictive permissions (0600 on Unix). The public key is added to
|
||||||
|
/// the vault's devices.json and committed to git.
|
||||||
|
///
|
||||||
|
/// Device keys are independent of the vault encryption key -- revoking a
|
||||||
|
/// device does not require rotating the passphrase or reference image.
|
||||||
fn cmd_device_add(name: String) -> Result<()> {
|
fn cmd_device_add(name: String) -> Result<()> {
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
|
|
||||||
let mut devices = read_devices()?;
|
let mut devices = read_devices()?;
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate device names
|
||||||
if devices.iter().any(|d| d.name == name) {
|
if devices.iter().any(|d| d.name == name) {
|
||||||
bail!("device '{}' already exists", name);
|
bail!("device '{}' already exists", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ed25519 keypair
|
// Generate ed25519 keypair using the OS CSPRNG
|
||||||
let signing_key = SigningKey::generate(&mut OsRng);
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
let verifying_key = signing_key.verifying_key();
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
|
||||||
let private_key_hex = hex::encode(signing_key.to_bytes());
|
let private_key_hex = hex::encode(signing_key.to_bytes());
|
||||||
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
||||||
|
|
||||||
// Save private key
|
// Save private key to the user's config directory (NOT in the vault)
|
||||||
let config_dir = dirs::config_dir()
|
let config_dir = dirs::config_dir()
|
||||||
.context("failed to find config directory")?
|
.context("failed to find config directory")?
|
||||||
.join("idfoto");
|
.join("idfoto");
|
||||||
@@ -646,7 +808,7 @@ fn cmd_device_add(name: String) -> Result<()> {
|
|||||||
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to devices.json
|
// Add public key to the vault's device registry
|
||||||
devices.push(DeviceEntry {
|
devices.push(DeviceEntry {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
public_key: public_key_hex,
|
public_key: public_key_hex,
|
||||||
@@ -660,6 +822,7 @@ fn cmd_device_add(name: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all registered devices with their public keys.
|
||||||
fn cmd_device_list() -> Result<()> {
|
fn cmd_device_list() -> Result<()> {
|
||||||
let devices = read_devices()?;
|
let devices = read_devices()?;
|
||||||
|
|
||||||
@@ -677,6 +840,13 @@ fn cmd_device_list() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Revoke a device by removing it from the device registry.
|
||||||
|
///
|
||||||
|
/// This is a metadata-only operation: the device's public key is removed from
|
||||||
|
/// devices.json, but the vault encryption key is NOT rotated. The revoked
|
||||||
|
/// device can no longer authenticate via its ed25519 key, but if it had
|
||||||
|
/// previously derived the master key (via passphrase + image), that key
|
||||||
|
/// remains valid until the user changes their passphrase or reference image.
|
||||||
fn cmd_device_revoke(name: String) -> Result<()> {
|
fn cmd_device_revoke(name: String) -> Result<()> {
|
||||||
let mut devices = read_devices()?;
|
let mut devices = read_devices()?;
|
||||||
let initial_len = devices.len();
|
let initial_len = devices.len();
|
||||||
@@ -695,6 +865,7 @@ fn cmd_device_revoke(name: String) -> Result<()> {
|
|||||||
|
|
||||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Entry point: parse CLI arguments and dispatch to the appropriate command handler.
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,48 @@
|
|||||||
|
//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption.
|
||||||
|
//!
|
||||||
|
//! This module implements the low-level "encrypt bytes / decrypt bytes" layer.
|
||||||
|
//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`].
|
||||||
|
//!
|
||||||
|
//! ## Why XChaCha20-Poly1305 over AES-GCM
|
||||||
|
//!
|
||||||
|
//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk
|
||||||
|
//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit
|
||||||
|
//! nonce, birthday-bound collisions become probable around 2^48 messages under
|
||||||
|
//! the same key -- a real concern for a long-lived vault.
|
||||||
|
//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher
|
||||||
|
//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is
|
||||||
|
//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable
|
||||||
|
//! to cache-timing side channels.
|
||||||
|
//!
|
||||||
|
//! ## Binary ciphertext format
|
||||||
|
//!
|
||||||
|
//! Every encrypted blob produced by [`encrypt`] has this layout:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! - **Version byte** (`0x01`): allows future format changes without ambiguity.
|
||||||
|
//! Decryption rejects any version it does not recognize.
|
||||||
|
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
|
||||||
|
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
|
||||||
|
//! nonce management.
|
||||||
|
//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is
|
||||||
|
//! appended by the cipher implementation; we do not separate it.
|
||||||
|
//!
|
||||||
|
//! ## KDF pipeline
|
||||||
|
//!
|
||||||
|
//! [`derive_master_key`] concatenates the passphrase and image_secret as a single
|
||||||
|
//! password input to Argon2id:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! password = passphrase_bytes || image_secret (32 bytes)
|
||||||
|
//! master_key = Argon2id(password, salt, params) -> 32 bytes
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Both factors contribute to the derived key -- compromising one without the
|
||||||
|
//! other is insufficient. The salt is vault-specific and stored in `.idfoto/salt`.
|
||||||
|
|
||||||
use argon2::{Algorithm, Argon2, Params, Version};
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
use chacha20poly1305::{
|
use chacha20poly1305::{
|
||||||
aead::{Aead, KeyInit},
|
aead::{Aead, KeyInit},
|
||||||
@@ -8,14 +53,35 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::error::{IdfotoError, Result};
|
use crate::error::{IdfotoError, Result};
|
||||||
|
|
||||||
|
/// Current binary format version. Increment this if the ciphertext layout changes.
|
||||||
const VERSION_BYTE: u8 = 0x01;
|
const VERSION_BYTE: u8 = 0x01;
|
||||||
|
|
||||||
|
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
|
||||||
const NONCE_LEN: usize = 24;
|
const NONCE_LEN: usize = 24;
|
||||||
|
|
||||||
|
/// Poly1305 authentication tag length: 128 bits = 16 bytes.
|
||||||
|
/// Used only for minimum-length validation during decryption.
|
||||||
const TAG_LEN: usize = 16;
|
const TAG_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// Total header size: version byte + nonce. The ciphertext (including tag)
|
||||||
|
/// follows immediately after the header.
|
||||||
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
|
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
|
||||||
|
|
||||||
|
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
|
||||||
|
///
|
||||||
|
/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`.
|
||||||
|
/// A fresh random nonce is generated for each call via the OS CSPRNG.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`IdfotoError::Encrypt`] if the underlying AEAD operation fails
|
||||||
|
/// (extremely unlikely in practice).
|
||||||
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||||
let cipher = XChaCha20Poly1305::new(key.into());
|
let cipher = XChaCha20Poly1305::new(key.into());
|
||||||
|
|
||||||
|
// Generate a fresh random 24-byte nonce for every encryption.
|
||||||
|
// With 192 bits of randomness, nonce reuse probability is negligible
|
||||||
|
// even across billions of encryptions under the same key.
|
||||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
let nonce = XNonce::from(nonce_bytes);
|
let nonce = XNonce::from(nonce_bytes);
|
||||||
@@ -33,7 +99,22 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt a blob produced by [`encrypt`], returning the original plaintext.
|
||||||
|
///
|
||||||
|
/// Validates the version byte and minimum blob length before attempting
|
||||||
|
/// authenticated decryption. If the key is wrong or the data has been
|
||||||
|
/// tampered with, the Poly1305 tag verification fails and [`IdfotoError::Decrypt`]
|
||||||
|
/// is returned -- with no information about which bytes were wrong (preventing
|
||||||
|
/// padding oracle / chosen-ciphertext attacks).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`IdfotoError::Format`] if the data is too short or has an unknown version byte.
|
||||||
|
/// - [`IdfotoError::Decrypt`] if the AEAD tag verification fails (wrong key or
|
||||||
|
/// tampered data).
|
||||||
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
|
||||||
|
// A zero-length plaintext produces exactly 41 bytes of output.
|
||||||
if data.len() < HEADER_LEN + TAG_LEN {
|
if data.len() < HEADER_LEN + TAG_LEN {
|
||||||
return Err(IdfotoError::Format(
|
return Err(IdfotoError::Format(
|
||||||
"data too short to be valid ciphertext".into(),
|
"data too short to be valid ciphertext".into(),
|
||||||
@@ -59,13 +140,36 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
|||||||
Ok(plaintext)
|
Ok(plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunable parameters for the Argon2id key derivation function.
|
||||||
|
///
|
||||||
|
/// These are stored in the vault's `.idfoto/params.json` so that every client
|
||||||
|
/// derives the same master key from the same inputs. Making them configurable
|
||||||
|
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
|
||||||
|
/// params (m=64MiB, t=3, p=4).
|
||||||
|
///
|
||||||
|
/// The parameters follow Argon2id naming conventions:
|
||||||
|
/// - `argon2_m`: memory cost in KiB
|
||||||
|
/// - `argon2_t`: time cost (number of iterations)
|
||||||
|
/// - `argon2_p`: parallelism degree (number of lanes)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct KdfParams {
|
pub struct KdfParams {
|
||||||
|
/// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC
|
||||||
|
/// brute-force attacks expensive. Tests use 256 KiB for speed.
|
||||||
pub argon2_m: u32,
|
pub argon2_m: u32,
|
||||||
|
/// Time cost (iteration count). Default is 3. Higher values increase CPU
|
||||||
|
/// time linearly. Combined with high memory cost, this makes each key
|
||||||
|
/// derivation take ~1 second on modern hardware.
|
||||||
pub argon2_t: u32,
|
pub argon2_t: u32,
|
||||||
|
/// Parallelism degree. Default is 4. Sets the number of independent lanes
|
||||||
|
/// in the Argon2id memory-hard computation.
|
||||||
pub argon2_p: u32,
|
pub argon2_p: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes.
|
||||||
|
///
|
||||||
|
/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU,
|
||||||
|
/// making brute-force attacks impractical while keeping interactive unlock fast
|
||||||
|
/// enough for daily use.
|
||||||
impl Default for KdfParams {
|
impl Default for KdfParams {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -76,6 +180,28 @@ impl Default for KdfParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a 256-bit master key from the user's passphrase and reference image secret.
|
||||||
|
///
|
||||||
|
/// The two factors (passphrase + image_secret) are concatenated into a single
|
||||||
|
/// password input to Argon2id. This means both factors contribute entropy to
|
||||||
|
/// the derived key -- compromising one factor alone is insufficient.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
|
||||||
|
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
|
||||||
|
/// [`crate::imgsecret::extract`].
|
||||||
|
/// - `salt`: a 32-byte vault-specific salt (stored in `.idfoto/salt`).
|
||||||
|
/// - `params`: the Argon2id tuning parameters (stored in `.idfoto/params.json`).
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`IdfotoError::Kdf`] if the Argon2id parameters are invalid (e.g.,
|
||||||
|
/// memory cost below the library's minimum).
|
||||||
pub fn derive_master_key(
|
pub fn derive_master_key(
|
||||||
passphrase: &[u8],
|
passphrase: &[u8],
|
||||||
image_secret: &[u8; 32],
|
image_secret: &[u8; 32],
|
||||||
@@ -92,7 +218,10 @@ pub fn derive_master_key(
|
|||||||
|
|
||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||||
|
|
||||||
// Concatenate passphrase + image_secret as the password input
|
// Concatenate passphrase + image_secret as the password input.
|
||||||
|
// This ensures both factors contribute to the derived key: knowing only
|
||||||
|
// the passphrase (without the reference image) or only the image secret
|
||||||
|
// (without the passphrase) is insufficient to derive the correct master key.
|
||||||
let mut password = Vec::with_capacity(passphrase.len() + 32);
|
let mut password = Vec::with_capacity(passphrase.len() + 32);
|
||||||
password.extend_from_slice(passphrase);
|
password.extend_from_slice(passphrase);
|
||||||
password.extend_from_slice(image_secret);
|
password.extend_from_slice(image_secret);
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
|
//! Vault data model: entries, manifest entries, and the manifest index.
|
||||||
|
//!
|
||||||
|
//! The vault stores credentials in two tiers:
|
||||||
|
//!
|
||||||
|
//! 1. **Individual entries** (`entries/<id>.enc`): each file contains a single
|
||||||
|
//! [`Entry`] encrypted with the master key. Only decrypted when the user
|
||||||
|
//! needs to read or edit a specific credential.
|
||||||
|
//!
|
||||||
|
//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a
|
||||||
|
//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries.
|
||||||
|
//! This lets the CLI list and search entries by decrypting only one file,
|
||||||
|
//! rather than decrypting every entry in the vault.
|
||||||
|
//!
|
||||||
|
//! ## Entry IDs
|
||||||
|
//!
|
||||||
|
//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy,
|
||||||
|
//! ~4 billion possible values). This is sufficient for family-scale vaults while
|
||||||
|
//! keeping filenames short and filesystem-friendly.
|
||||||
|
//!
|
||||||
|
//! ## Serialization strategy
|
||||||
|
//!
|
||||||
|
//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields
|
||||||
|
//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact
|
||||||
|
//! -- omitting null fields reduces ciphertext size and avoids leaking structural
|
||||||
|
//! information about which optional fields a credential uses.
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// A single password entry (stored encrypted in entries/<id>.enc).
|
/// A full credential entry stored encrypted in `entries/<id>.enc`.
|
||||||
|
///
|
||||||
|
/// Contains all sensitive data for a single credential. Each entry is encrypted
|
||||||
|
/// independently, so accessing one entry does not require decrypting others.
|
||||||
|
///
|
||||||
|
/// ## Fields
|
||||||
|
///
|
||||||
|
/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required.
|
||||||
|
/// - `url`: the login URL. Optional; used for autofill matching in the browser extension.
|
||||||
|
/// - `username`: the account username or email. Optional.
|
||||||
|
/// - `password`: the credential password. Required (this is the core secret).
|
||||||
|
/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional.
|
||||||
|
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
|
||||||
|
/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created.
|
||||||
|
/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -19,25 +59,46 @@ pub struct Entry {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summary info about an entry (stored in the manifest).
|
/// Summary metadata for a single entry, stored in the manifest.
|
||||||
|
///
|
||||||
|
/// This is a lightweight projection of [`Entry`] that contains only the
|
||||||
|
/// non-sensitive fields needed for listing and searching. The password,
|
||||||
|
/// notes, and TOTP secret are intentionally excluded so that listing
|
||||||
|
/// entries requires decrypting only the manifest, not every individual entry.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ManifestEntry {
|
pub struct ManifestEntry {
|
||||||
|
/// Human-readable label for display and search matching.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Login URL for search matching and browser extension autofill.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
|
/// Account username for display in entry listings.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
|
/// Timestamp of last modification, used for sorting and display.
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The vault manifest — maps entry IDs to their metadata.
|
/// The vault manifest -- an encrypted index mapping entry IDs to their metadata.
|
||||||
|
///
|
||||||
|
/// The manifest serves two purposes:
|
||||||
|
///
|
||||||
|
/// 1. **Efficient listing**: decrypting the single manifest file is enough to show
|
||||||
|
/// all entry names, URLs, and usernames without touching individual entry files.
|
||||||
|
/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive
|
||||||
|
/// substring matching against entry names and URLs.
|
||||||
|
///
|
||||||
|
/// The `version` field allows future schema migrations if the manifest format evolves.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Manifest {
|
pub struct Manifest {
|
||||||
|
/// Map from entry ID (8-char hex string) to entry metadata.
|
||||||
pub entries: HashMap<String, ManifestEntry>,
|
pub entries: HashMap<String, ManifestEntry>,
|
||||||
|
/// Schema version. Currently always `1`.
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Manifest {
|
impl Manifest {
|
||||||
|
/// Create a new empty manifest with version 1.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Manifest {
|
Manifest {
|
||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
@@ -45,14 +106,23 @@ impl Manifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert or update an entry in the manifest.
|
||||||
|
///
|
||||||
|
/// If an entry with the same ID already exists, it is overwritten.
|
||||||
|
/// This is used both for `add` (new entry) and `edit` (update existing).
|
||||||
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
|
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
|
||||||
self.entries.insert(id, entry);
|
self.entries.insert(id, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove an entry from the manifest by ID, returning its metadata if it existed.
|
||||||
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
|
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
|
||||||
self.entries.remove(id)
|
self.entries.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search entries by case-insensitive substring match against name and URL.
|
||||||
|
///
|
||||||
|
/// Returns a vector of `(id, entry)` pairs for all matching entries. An entry
|
||||||
|
/// matches if the query appears in its name or URL (case-insensitive).
|
||||||
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
|
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
|
||||||
let q = query.to_lowercase();
|
let q = query.to_lowercase();
|
||||||
self.entries
|
self.entries
|
||||||
@@ -75,6 +145,10 @@ impl Default for Manifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a random 8-character hex string to use as an entry ID.
|
/// Generate a random 8-character hex string to use as an entry ID.
|
||||||
|
///
|
||||||
|
/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`.
|
||||||
|
/// This gives ~4 billion possible values, which is more than sufficient for
|
||||||
|
/// a family-scale vault (typically < 1000 entries).
|
||||||
pub fn generate_entry_id() -> String {
|
pub fn generate_entry_id() -> String {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let bytes: [u8; 4] = rng.gen();
|
let bytes: [u8; 4] = rng.gen();
|
||||||
|
|||||||
@@ -1,25 +1,59 @@
|
|||||||
|
//! Unified error type for the idfoto-core crate.
|
||||||
|
//!
|
||||||
|
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
|
||||||
|
//! for `std::result::Result<T, IdfotoError>`. Using a single error enum keeps the
|
||||||
|
//! public API surface predictable and makes error handling in callers (CLI, WASM
|
||||||
|
//! bindings, mobile FFI) straightforward.
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// All errors that can originate from idfoto-core operations.
|
||||||
|
///
|
||||||
|
/// Variants are ordered roughly by the pipeline stage where they occur:
|
||||||
|
/// KDF -> encryption -> decryption -> format parsing -> entry lookup -> image
|
||||||
|
/// steganography -> serialization -> device keys.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum IdfotoError {
|
pub enum IdfotoError {
|
||||||
|
/// The Argon2id key derivation failed. This typically means invalid KDF
|
||||||
|
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
|
||||||
#[error("key derivation failed: {0}")]
|
#[error("key derivation failed: {0}")]
|
||||||
Kdf(String),
|
Kdf(String),
|
||||||
|
|
||||||
|
/// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare
|
||||||
|
/// -- the only realistic cause is an internal library error, since the cipher
|
||||||
|
/// accepts arbitrary-length plaintext.
|
||||||
#[error("encryption failed: {0}")]
|
#[error("encryption failed: {0}")]
|
||||||
Encrypt(String),
|
Encrypt(String),
|
||||||
|
|
||||||
|
/// Authenticated decryption failed. This means either the wrong master key
|
||||||
|
/// was used (wrong passphrase or wrong reference image) or the ciphertext
|
||||||
|
/// was tampered with / corrupted in transit or at rest. The error message is
|
||||||
|
/// intentionally vague to avoid leaking information about which factor was
|
||||||
|
/// wrong (passphrase vs. image).
|
||||||
#[error("decryption failed: wrong key or corrupted data")]
|
#[error("decryption failed: wrong key or corrupted data")]
|
||||||
Decrypt,
|
Decrypt,
|
||||||
|
|
||||||
|
/// The binary ciphertext blob does not match the expected format (e.g.,
|
||||||
|
/// too short to contain the version byte + nonce + tag, or an unrecognized
|
||||||
|
/// version byte). This usually indicates file corruption or a version
|
||||||
|
/// mismatch between the writer and reader.
|
||||||
#[error("invalid vault format: {0}")]
|
#[error("invalid vault format: {0}")]
|
||||||
Format(String),
|
Format(String),
|
||||||
|
|
||||||
|
/// A vault entry was looked up by ID but does not exist in the manifest.
|
||||||
|
/// The string payload is the missing entry ID.
|
||||||
#[error("entry not found: {0}")]
|
#[error("entry not found: {0}")]
|
||||||
EntryNotFound(String),
|
EntryNotFound(String),
|
||||||
|
|
||||||
|
/// A general error from the image steganography subsystem (imgsecret).
|
||||||
|
/// Covers issues like failing to decode the carrier JPEG or failing to
|
||||||
|
/// encode the output JPEG after modification.
|
||||||
#[error("imgsecret: {0}")]
|
#[error("imgsecret: {0}")]
|
||||||
ImgSecret(String),
|
ImgSecret(String),
|
||||||
|
|
||||||
|
/// The carrier image is too small to hold the embedded secret with
|
||||||
|
/// sufficient redundancy. The embed region (central 70% of the image)
|
||||||
|
/// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks.
|
||||||
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
||||||
ImageTooSmall {
|
ImageTooSmall {
|
||||||
min_width: u32,
|
min_width: u32,
|
||||||
@@ -28,14 +62,25 @@ pub enum IdfotoError {
|
|||||||
actual_height: u32,
|
actual_height: u32,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Secret extraction from a JPEG failed. This can mean:
|
||||||
|
/// - The image never had a secret embedded in it.
|
||||||
|
/// - The image was recompressed below Q85, destroying the QIM watermarks.
|
||||||
|
/// - The image was cropped beyond the 15% crumple zone.
|
||||||
|
/// - Majority-vote confidence fell below the 60% threshold on one or more bits.
|
||||||
#[error("extraction failed: no valid secret found in image")]
|
#[error("extraction failed: no valid secret found in image")]
|
||||||
ExtractionFailed,
|
ExtractionFailed,
|
||||||
|
|
||||||
|
/// JSON serialization or deserialization of an entry or manifest failed.
|
||||||
|
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
|
||||||
#[error("json error: {0}")]
|
#[error("json error: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// An error related to device ed25519 key operations. Device keys are
|
||||||
|
/// separate from the vault KDF -- revoking a device does not require
|
||||||
|
/// rotating the passphrase or reference image.
|
||||||
#[error("device key error: {0}")]
|
#[error("device key error: {0}")]
|
||||||
DeviceKey(String),
|
DeviceKey(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
||||||
|
|||||||
@@ -1,7 +1,43 @@
|
|||||||
//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping.
|
//! DCT-based steganographic embedding of a 256-bit secret in JPEG images.
|
||||||
//!
|
//!
|
||||||
//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance
|
//! This is the novel component of idfoto. It hides a 32-byte secret inside a
|
||||||
//! channel using Quantization Index Modulation (QIM) with majority voting.
|
//! JPEG image's luminance channel using Quantization Index Modulation (QIM) on
|
||||||
|
//! mid-frequency DCT coefficients, with majority voting across multiple redundant
|
||||||
|
//! copies for robustness.
|
||||||
|
//!
|
||||||
|
//! ## High-level algorithm
|
||||||
|
//!
|
||||||
|
//! ### Embedding (`embed`)
|
||||||
|
//!
|
||||||
|
//! 1. Decode the carrier JPEG and extract the luminance (Y) channel.
|
||||||
|
//! 2. Compute the "embed region" -- the central 70% of the image (15% margin
|
||||||
|
//! on each side acts as a crumple zone for mild cropping).
|
||||||
|
//! 3. Divide the embed region into 8x8 pixel blocks and select evenly-spaced
|
||||||
|
//! blocks for embedding.
|
||||||
|
//! 4. For each copy of the secret (5-50 copies depending on image size):
|
||||||
|
//! - For each of the 22 blocks needed to hold 256 bits (12 bits per block):
|
||||||
|
//! - Apply the 2D DCT to the 8x8 block.
|
||||||
|
//! - Embed bits into 12 mid-frequency DCT coefficients using QIM.
|
||||||
|
//! - Apply the inverse DCT to write the modified block back.
|
||||||
|
//! 5. Reconstruct the JPEG by replacing only the Y channel and re-encoding.
|
||||||
|
//!
|
||||||
|
//! ### Extraction (`extract`)
|
||||||
|
//!
|
||||||
|
//! 1. Decode the JPEG and extract the Y channel.
|
||||||
|
//! 2. Try the canonical extraction (assuming the image is uncropped).
|
||||||
|
//! 3. If that fails, try crop-recovery: search for plausible original dimensions
|
||||||
|
//! and pixel offsets, reconstructing the block grid accordingly.
|
||||||
|
//! 4. For each copy of the secret, extract bits from DCT coefficients via QIM.
|
||||||
|
//! 5. Majority-vote each bit position across all copies. Require >= 60% confidence.
|
||||||
|
//!
|
||||||
|
//! ## Robustness
|
||||||
|
//!
|
||||||
|
//! The combination of QIM with a high quantization step (50.0), mid-frequency
|
||||||
|
//! coefficient placement, and majority voting across many copies makes the
|
||||||
|
//! watermark survive:
|
||||||
|
//! - JPEG recompression down to quality ~85
|
||||||
|
//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone)
|
||||||
|
//! - Color space conversions (embedding is in luminance only)
|
||||||
|
|
||||||
use crate::error::{IdfotoError, Result};
|
use crate::error::{IdfotoError, Result};
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
@@ -12,15 +48,55 @@ use std::io::Cursor;
|
|||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// DCT block size. JPEG uses 8x8 blocks, so we match that to minimize
|
||||||
|
/// interference with the JPEG codec's own quantization.
|
||||||
const BLOCK_SIZE: usize = 8;
|
const BLOCK_SIZE: usize = 8;
|
||||||
|
|
||||||
|
/// QIM quantization step. Higher values make the watermark more robust to
|
||||||
|
/// recompression but introduce more visible artifacts. A value of 50.0 is
|
||||||
|
/// higher than the typical academic value of 25 -- this is intentional because
|
||||||
|
/// we need to survive JPEG recompression at Q85 and below, which applies
|
||||||
|
/// aggressive quantization to mid-frequency coefficients. The trade-off is
|
||||||
|
/// acceptable because the reference image is a personal photo, not a
|
||||||
|
/// publication-quality image.
|
||||||
const QUANT_STEP: f64 = 50.0;
|
const QUANT_STEP: f64 = 50.0;
|
||||||
|
|
||||||
|
/// Minimum image dimension (width or height) in pixels. Images smaller than
|
||||||
|
/// this cannot hold enough 8x8 blocks for reliable embedding.
|
||||||
const MIN_DIMENSION: u32 = 100;
|
const MIN_DIMENSION: u32 = 100;
|
||||||
|
|
||||||
|
/// Number of secret bits to embed: 256 bits = 32 bytes.
|
||||||
const SECRET_BITS: usize = 256;
|
const SECRET_BITS: usize = 256;
|
||||||
|
|
||||||
|
/// Minimum number of redundant copies of the secret. More copies improve
|
||||||
|
/// extraction reliability via majority voting, but require more blocks.
|
||||||
const MIN_COPIES: usize = 5;
|
const MIN_COPIES: usize = 5;
|
||||||
|
|
||||||
|
/// Number of mid-frequency DCT positions used per block. Each block carries
|
||||||
|
/// 12 bits of the secret. This matches `EMBED_POSITIONS.len()`.
|
||||||
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
||||||
|
|
||||||
|
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||||
|
/// ceil(256 / 12) = 22 blocks per copy.
|
||||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
||||||
|
|
||||||
/// Mid-frequency DCT positions (zig-zag positions 4–15)
|
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||||
|
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||||
|
///
|
||||||
|
/// These correspond to zig-zag scan positions 4 through 15 -- the "sweet spot"
|
||||||
|
/// between low-frequency coefficients (which carry visible image structure and
|
||||||
|
/// are heavily quantized by JPEG) and high-frequency coefficients (which carry
|
||||||
|
/// noise/detail and are aggressively zeroed by JPEG compression).
|
||||||
|
///
|
||||||
|
/// Mid-frequency coefficients survive JPEG recompression better than high-frequency
|
||||||
|
/// ones, while causing less visible distortion than modifying low-frequency ones.
|
||||||
|
///
|
||||||
|
/// The zig-zag ordering is the standard JPEG scan order:
|
||||||
|
/// ```text
|
||||||
|
/// Zig-zag positions 4-7: (0,3) (1,2) (2,1) (3,0)
|
||||||
|
/// Zig-zag positions 8-11: (0,4) (1,3) (2,2) (3,1)
|
||||||
|
/// Zig-zag positions 12-15: (4,0) (0,5) (1,4) (2,3)
|
||||||
|
/// ```
|
||||||
const EMBED_POSITIONS: [(usize, usize); 12] = [
|
const EMBED_POSITIONS: [(usize, usize); 12] = [
|
||||||
(0, 3),
|
(0, 3),
|
||||||
(1, 2),
|
(1, 2),
|
||||||
@@ -38,17 +114,31 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [
|
|||||||
|
|
||||||
// ─── YChannel ────────────────────────────────────────────────────────────────
|
// ─── YChannel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The luminance (Y) channel of an image, stored as a flat array of f64 values.
|
||||||
|
///
|
||||||
|
/// We embed exclusively in the luminance channel because:
|
||||||
|
/// - Human vision is more sensitive to luminance than chrominance, so the
|
||||||
|
/// luminance channel has more "room" for watermarking in the frequency domain.
|
||||||
|
/// - JPEG compresses chrominance channels more aggressively (typically 4:2:0
|
||||||
|
/// subsampling), which would destroy embedded data.
|
||||||
|
/// - Working with a single channel keeps the DCT operations simple and fast.
|
||||||
struct YChannel {
|
struct YChannel {
|
||||||
|
/// Row-major luminance values. `data[y * width + x]` gives the luminance
|
||||||
|
/// at pixel (x, y). Values are in the range [0, 255] after extraction
|
||||||
|
/// from RGB, but may temporarily go slightly outside this range during
|
||||||
|
/// DCT manipulation.
|
||||||
data: Vec<f64>,
|
data: Vec<f64>,
|
||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YChannel {
|
impl YChannel {
|
||||||
|
/// Get the luminance value at pixel (x, y).
|
||||||
fn get(&self, x: usize, y: usize) -> f64 {
|
fn get(&self, x: usize, y: usize) -> f64 {
|
||||||
self.data[y * self.width + x]
|
self.data[y * self.width + x]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the luminance value at pixel (x, y).
|
||||||
fn set(&mut self, x: usize, y: usize, val: f64) {
|
fn set(&mut self, x: usize, y: usize, val: f64) {
|
||||||
self.data[y * self.width + x] = val;
|
self.data[y * self.width + x] = val;
|
||||||
}
|
}
|
||||||
@@ -56,19 +146,36 @@ impl YChannel {
|
|||||||
|
|
||||||
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
|
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Defines the central region of the image where embedding occurs.
|
||||||
|
///
|
||||||
|
/// The embed region is the central 70% of the image -- a 15% margin is excluded
|
||||||
|
/// on each side. This margin acts as a "crumple zone": if the image is mildly
|
||||||
|
/// cropped (e.g., a social media platform trims edges), the embedded data in the
|
||||||
|
/// center remains intact. The 15% margin is sufficient to tolerate up to ~10%
|
||||||
|
/// cropping from any single edge.
|
||||||
struct EmbedRegion {
|
struct EmbedRegion {
|
||||||
|
/// Pixel offset from the left edge to the start of the embed region.
|
||||||
x_offset: usize,
|
x_offset: usize,
|
||||||
|
/// Pixel offset from the top edge to the start of the embed region.
|
||||||
y_offset: usize,
|
y_offset: usize,
|
||||||
|
/// Width of the embed region in pixels.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
region_width: usize,
|
region_width: usize,
|
||||||
|
/// Height of the embed region in pixels.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
region_height: usize,
|
region_height: usize,
|
||||||
|
/// Number of complete 8x8 blocks that fit horizontally in the embed region.
|
||||||
blocks_x: usize,
|
blocks_x: usize,
|
||||||
|
/// Number of complete 8x8 blocks that fit vertically in the embed region.
|
||||||
blocks_y: usize,
|
blocks_y: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper functions ────────────────────────────────────────────────────────
|
// ─── Helper functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decode a JPEG from raw bytes and extract the luminance (Y) channel.
|
||||||
|
///
|
||||||
|
/// Converts each RGB pixel to luminance using the ITU-R BT.601 formula:
|
||||||
|
/// `Y = 0.299*R + 0.587*G + 0.114*B`
|
||||||
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||||||
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
@@ -82,6 +189,7 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
|||||||
for y in 0..height {
|
for y in 0..height {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
let p = rgb.get_pixel(x as u32, y as u32);
|
let p = rgb.get_pixel(x as u32, y as u32);
|
||||||
|
// ITU-R BT.601 luma coefficients
|
||||||
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
|
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
|
||||||
data.push(luma);
|
data.push(luma);
|
||||||
}
|
}
|
||||||
@@ -93,10 +201,15 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the embed region for a YChannel (convenience wrapper).
|
||||||
fn central_region(y: &YChannel) -> EmbedRegion {
|
fn central_region(y: &YChannel) -> EmbedRegion {
|
||||||
compute_region(y.width, y.height)
|
compute_region(y.width, y.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the central embed region for given image dimensions.
|
||||||
|
///
|
||||||
|
/// The region excludes a 15% margin on each side, leaving the central 70%.
|
||||||
|
/// The margin acts as a crumple zone for crop tolerance.
|
||||||
fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
||||||
let margin_x = (width as f64 * 0.15) as usize;
|
let margin_x = (width as f64 * 0.15) as usize;
|
||||||
let margin_y = (height as f64 * 0.15) as usize;
|
let margin_y = (height as f64 * 0.15) as usize;
|
||||||
@@ -116,6 +229,11 @@ fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an 8x8 pixel block from the Y channel at absolute pixel coordinates.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the block would extend beyond the image boundaries
|
||||||
|
/// (used during crop-recovery extraction where some blocks may have been
|
||||||
|
/// cropped away).
|
||||||
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||||
if px + 8 > y.width || py + 8 > y.height {
|
if px + 8 > y.width || py + 8 > y.height {
|
||||||
return None;
|
return None;
|
||||||
@@ -129,12 +247,16 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
|||||||
Some(block)
|
Some(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an 8x8 block from the Y channel using block coordinates relative to
|
||||||
|
/// the embed region.
|
||||||
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
|
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
|
||||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
read_block_abs(y, start_x, start_y).unwrap()
|
read_block_abs(y, start_x, start_y).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write an 8x8 block back to the Y channel using block coordinates relative
|
||||||
|
/// to the embed region.
|
||||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
@@ -146,7 +268,22 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── DCT ─────────────────────────────────────────────────────────────────────
|
// ─── DCT ─────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The Discrete Cosine Transform (DCT) converts a spatial-domain signal (pixel
|
||||||
|
// values) into a frequency-domain representation (coefficients). JPEG compression
|
||||||
|
// itself uses the 8x8 Type-II DCT, so working in the same domain lets us embed
|
||||||
|
// data where JPEG's own quantization is least destructive.
|
||||||
|
//
|
||||||
|
// We implement the DCT from scratch (rather than depending on a library) to keep
|
||||||
|
// the crate dependency-light and WASM-friendly. The 8x8 size is small enough
|
||||||
|
// that the naive O(N^2) computation is fast.
|
||||||
|
|
||||||
|
/// 1D Type-II DCT of an 8-element signal.
|
||||||
|
///
|
||||||
|
/// Applies the orthonormal DCT-II:
|
||||||
|
/// X[k] = c(k) * sum_{i=0}^{7} x[i] * cos((2i+1)*k*pi/16)
|
||||||
|
///
|
||||||
|
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for k in 0..8 {
|
for k in 0..8 {
|
||||||
@@ -164,6 +301,10 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 1D Type-III DCT (inverse DCT) of an 8-element signal.
|
||||||
|
///
|
||||||
|
/// Reconstructs the spatial-domain signal from DCT coefficients:
|
||||||
|
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
@@ -181,11 +322,18 @@ fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 2D DCT of an 8x8 block, computed as separable 1D DCTs.
|
||||||
|
///
|
||||||
|
/// First applies the 1D DCT to each row, then to each column of the result.
|
||||||
|
/// This is mathematically equivalent to the full 2D DCT but faster (O(N^3)
|
||||||
|
/// instead of O(N^4) for the naive 2D formulation).
|
||||||
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||||
|
// Step 1: DCT along rows
|
||||||
let mut temp = [[0.0f64; 8]; 8];
|
let mut temp = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for row in 0..8 {
|
||||||
temp[row] = dct1d(&block[row]);
|
temp[row] = dct1d(&block[row]);
|
||||||
}
|
}
|
||||||
|
// Step 2: DCT along columns
|
||||||
let mut result = [[0.0f64; 8]; 8];
|
let mut result = [[0.0f64; 8]; 8];
|
||||||
for col in 0..8 {
|
for col in 0..8 {
|
||||||
let mut column = [0.0f64; 8];
|
let mut column = [0.0f64; 8];
|
||||||
@@ -200,7 +348,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 2D inverse DCT of an 8x8 block, computed as separable 1D inverse DCTs.
|
||||||
|
///
|
||||||
|
/// Reverses the 2D DCT: first applies IDCT along columns, then along rows.
|
||||||
|
/// (The order is reversed compared to the forward transform.)
|
||||||
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||||
|
// Step 1: IDCT along columns
|
||||||
let mut temp = [[0.0f64; 8]; 8];
|
let mut temp = [[0.0f64; 8]; 8];
|
||||||
for col in 0..8 {
|
for col in 0..8 {
|
||||||
let mut column = [0.0f64; 8];
|
let mut column = [0.0f64; 8];
|
||||||
@@ -212,6 +365,7 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
|||||||
temp[row][col] = transformed[row];
|
temp[row][col] = transformed[row];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Step 2: IDCT along rows
|
||||||
let mut result = [[0.0f64; 8]; 8];
|
let mut result = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for row in 0..8 {
|
||||||
result[row] = idct1d(&temp[row]);
|
result[row] = idct1d(&temp[row]);
|
||||||
@@ -220,7 +374,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── QIM ─────────────────────────────────────────────────────────────────────
|
// ─── QIM ─────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Quantization Index Modulation (QIM) is the core technique for encoding bits
|
||||||
|
// into DCT coefficients. It works by quantizing each coefficient to one of two
|
||||||
|
// interleaved grids, where the grid selection encodes the bit value.
|
||||||
|
//
|
||||||
|
// For bit 0: quantize to the nearest multiple of Q (grid: ..., -Q, 0, Q, 2Q, ...)
|
||||||
|
// For bit 1: quantize to the nearest multiple of Q, offset by Q/2 (grid: ..., -Q/2, Q/2, 3Q/2, ...)
|
||||||
|
//
|
||||||
|
// Extraction simply measures which grid the coefficient is closest to.
|
||||||
|
//
|
||||||
|
// QIM is preferred over spread-spectrum or LSB methods because it is:
|
||||||
|
// - Robust to recompression (the quantization step is larger than JPEG's own)
|
||||||
|
// - Simple to implement and analyze
|
||||||
|
// - Deterministic (no pseudo-random spreading sequence to synchronize)
|
||||||
|
|
||||||
|
/// Embed a single bit into a DCT coefficient using QIM.
|
||||||
|
///
|
||||||
|
/// Quantizes the coefficient to the nearest point on the grid selected by `bit`:
|
||||||
|
/// - `bit=0`: grid at multiples of `q` (i.e., 0, q, 2q, ...)
|
||||||
|
/// - `bit=1`: grid at multiples of `q` offset by `q/2` (i.e., q/2, 3q/2, ...)
|
||||||
|
///
|
||||||
|
/// The returned value is the modified coefficient.
|
||||||
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
||||||
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
|
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
|
||||||
let shifted = coef - offset;
|
let shifted = coef - offset;
|
||||||
@@ -228,8 +403,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
|||||||
quantized + offset
|
quantized + offset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a single bit from a DCT coefficient using QIM.
|
||||||
|
///
|
||||||
|
/// Computes the distance from the coefficient to each grid (bit-0 grid and
|
||||||
|
/// bit-1 grid) and returns whichever grid is closer. This is the ML (maximum
|
||||||
|
/// likelihood) decoder for QIM under additive noise.
|
||||||
fn qim_extract(coef: f64, q: f64) -> u8 {
|
fn qim_extract(coef: f64, q: f64) -> u8 {
|
||||||
|
// Distance to the nearest bit-0 grid point
|
||||||
let d0 = (coef - (coef / q).round() * q).abs();
|
let d0 = (coef - (coef / q).round() * q).abs();
|
||||||
|
// Distance to the nearest bit-1 grid point (offset by q/2)
|
||||||
let offset = q / 2.0;
|
let offset = q / 2.0;
|
||||||
let shifted = coef - offset;
|
let shifted = coef - offset;
|
||||||
let d1 = (shifted - (shifted / q).round() * q).abs();
|
let d1 = (shifted - (shifted / q).round() * q).abs();
|
||||||
@@ -238,6 +420,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 {
|
|||||||
|
|
||||||
// ─── Bit conversion ──────────────────────────────────────────────────────────
|
// ─── Bit conversion ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Convert a byte slice to a vector of individual bits (MSB first).
|
||||||
|
///
|
||||||
|
/// Each byte is expanded to 8 bits, with bit 7 (MSB) first.
|
||||||
|
/// Example: `[0xCA]` -> `[1, 1, 0, 0, 1, 0, 1, 0]`
|
||||||
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||||
for &byte in bytes {
|
for &byte in bytes {
|
||||||
@@ -248,6 +434,9 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
|||||||
bits
|
bits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a vector of individual bits (MSB first) back to bytes.
|
||||||
|
///
|
||||||
|
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||||
for chunk in bits.chunks(8) {
|
for chunk in bits.chunks(8) {
|
||||||
@@ -263,7 +452,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
|||||||
// ─── Block selection ─────────────────────────────────────────────────────────
|
// ─── Block selection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Compute the absolute pixel positions of embed blocks for a given image size.
|
/// Compute the absolute pixel positions of embed blocks for a given image size.
|
||||||
/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks.
|
///
|
||||||
|
/// This function deterministically maps image dimensions to a list of block
|
||||||
|
/// positions. Both the embedder and extractor call this function with the same
|
||||||
|
/// dimensions to agree on where blocks are. During crop recovery, the extractor
|
||||||
|
/// tries different assumed original dimensions to find the correct grid.
|
||||||
|
///
|
||||||
|
/// Returns `Vec<(px, py)>` -- top-left corners of 8x8 blocks in pixel coordinates.
|
||||||
|
/// Returns an empty vec if the image is too small to embed.
|
||||||
|
///
|
||||||
|
/// Blocks are selected with even spacing (stride) across the embed region to
|
||||||
|
/// spread the watermark uniformly, making it more resilient to localized damage.
|
||||||
|
/// The number of copies is capped at 50 to avoid diminishing returns.
|
||||||
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
|
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
|
||||||
let region = compute_region(img_width, img_height);
|
let region = compute_region(img_width, img_height);
|
||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
@@ -273,6 +473,7 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
|||||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||||
let target_count = num_copies * BLOCKS_PER_COPY;
|
let target_count = num_copies * BLOCKS_PER_COPY;
|
||||||
|
|
||||||
|
// Stride ensures blocks are evenly distributed across the embed region
|
||||||
let stride = (total_blocks / target_count).max(1);
|
let stride = (total_blocks / target_count).max(1);
|
||||||
let mut positions = Vec::with_capacity(target_count);
|
let mut positions = Vec::with_capacity(target_count);
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
@@ -287,11 +488,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
|||||||
positions
|
positions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select embed blocks using block-coordinate indices relative to the embed region.
|
||||||
|
///
|
||||||
|
/// Similar to [`compute_embed_positions`] but returns `(bx, by)` block indices
|
||||||
|
/// rather than absolute pixel positions. Used during embedding where block
|
||||||
|
/// coordinates are more convenient for the read_block/write_block API.
|
||||||
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
|
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
|
||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
if total_blocks == 0 || target_count == 0 {
|
if total_blocks == 0 || target_count == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// Even stride distributes blocks uniformly across the region
|
||||||
let stride = (total_blocks / target_count).max(1);
|
let stride = (total_blocks / target_count).max(1);
|
||||||
let mut blocks = Vec::with_capacity(target_count);
|
let mut blocks = Vec::with_capacity(target_count);
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
@@ -306,6 +513,17 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
|
|||||||
|
|
||||||
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
|
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Reconstruct a JPEG image after modifying its luminance channel.
|
||||||
|
///
|
||||||
|
/// This function takes the original JPEG (for its Cb/Cr chrominance data) and
|
||||||
|
/// the modified Y channel, then:
|
||||||
|
///
|
||||||
|
/// 1. Decodes the original JPEG to get per-pixel Cb and Cr values.
|
||||||
|
/// 2. For each pixel, combines the modified Y with the original Cb/Cr.
|
||||||
|
/// 3. Converts YCbCr back to RGB using the ITU-R BT.601 inverse formula.
|
||||||
|
/// 4. Re-encodes as JPEG at quality 92 (high enough to preserve the watermark).
|
||||||
|
///
|
||||||
|
/// Only the luminance changes; chrominance is preserved from the original.
|
||||||
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
||||||
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
@@ -325,12 +543,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
|||||||
let g = orig[1] as f64;
|
let g = orig[1] as f64;
|
||||||
let b = orig[2] as f64;
|
let b = orig[2] as f64;
|
||||||
|
|
||||||
|
// Extract Cb and Cr from the original pixel (we only modify Y)
|
||||||
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
|
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
|
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
|
||||||
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
|
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
|
||||||
|
|
||||||
|
// Use the modified Y value from our watermarked luminance channel
|
||||||
let y_new = y_modified.get(px as usize, py as usize);
|
let y_new = y_modified.get(px as usize, py as usize);
|
||||||
|
|
||||||
|
// Convert YCbCr -> RGB using ITU-R BT.601 inverse
|
||||||
let r_new = y_new + 1.402 * (cr - 128.0);
|
let r_new = y_new + 1.402 * (cr - 128.0);
|
||||||
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
|
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
|
||||||
let b_new = y_new + 1.772 * (cb - 128.0);
|
let b_new = y_new + 1.772 * (cb - 128.0);
|
||||||
@@ -357,7 +578,28 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
|||||||
|
|
||||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes.
|
/// Embed a 256-bit secret into a carrier JPEG image.
|
||||||
|
///
|
||||||
|
/// Returns the modified JPEG bytes with the secret hidden in the luminance
|
||||||
|
/// channel's mid-frequency DCT coefficients.
|
||||||
|
///
|
||||||
|
/// ## Pipeline
|
||||||
|
///
|
||||||
|
/// 1. Decode the carrier and extract the Y (luminance) channel.
|
||||||
|
/// 2. Validate that the image is large enough (>= 100x100 pixels, and enough
|
||||||
|
/// blocks in the central region for at least 5 redundant copies).
|
||||||
|
/// 3. Compute how many copies fit (up to 50) and select evenly-spaced blocks.
|
||||||
|
/// 4. For each copy, iterate through the 22 blocks that hold 256 bits:
|
||||||
|
/// - Forward DCT the 8x8 block.
|
||||||
|
/// - Embed 12 bits per block into the mid-frequency coefficients via QIM.
|
||||||
|
/// - Inverse DCT to write the modified spatial-domain values back.
|
||||||
|
/// 5. Reconstruct the JPEG with the modified Y channel and original Cb/Cr.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`IdfotoError::ImageTooSmall`] if the image is below minimum dimensions
|
||||||
|
/// or does not have enough blocks for reliable embedding.
|
||||||
|
/// - [`IdfotoError::ImgSecret`] if the image cannot be decoded or re-encoded.
|
||||||
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||||
let mut y = extract_y_channel(carrier_jpeg)?;
|
let mut y = extract_y_channel(carrier_jpeg)?;
|
||||||
|
|
||||||
@@ -382,12 +624,15 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap at 50 copies -- beyond that, additional redundancy has diminishing
|
||||||
|
// returns and the image modification becomes more visible.
|
||||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||||
let bits = bytes_to_bits(secret);
|
let bits = bytes_to_bits(secret);
|
||||||
|
|
||||||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||||||
let embed_blocks = select_embed_blocks(®ion, blocks_needed);
|
let embed_blocks = select_embed_blocks(®ion, blocks_needed);
|
||||||
|
|
||||||
|
// Embed each copy of the secret into its assigned blocks
|
||||||
for copy in 0..num_copies {
|
for copy in 0..num_copies {
|
||||||
for block_idx in 0..BLOCKS_PER_COPY {
|
for block_idx in 0..BLOCKS_PER_COPY {
|
||||||
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
|
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
|
||||||
@@ -398,6 +643,8 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
let mut block = read_block(&y, bx, by, ®ion);
|
let mut block = read_block(&y, bx, by, ®ion);
|
||||||
let mut dct = dct2_8x8(&block);
|
let mut dct = dct2_8x8(&block);
|
||||||
|
|
||||||
|
// Embed up to 12 bits (BITS_PER_BLOCK) in this block's
|
||||||
|
// mid-frequency DCT coefficients
|
||||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||||
if bit_idx >= SECRET_BITS {
|
if bit_idx >= SECRET_BITS {
|
||||||
@@ -414,14 +661,31 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
reconstruct_jpeg(carrier_jpeg, &y)
|
reconstruct_jpeg(carrier_jpeg, &y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
|
/// Extract a 256-bit secret from a (possibly re-encoded or mildly cropped) JPEG.
|
||||||
|
///
|
||||||
|
/// Delegates to [`extract_with_crop_recovery`] which first tries canonical
|
||||||
|
/// extraction (assuming the image has its original dimensions), then falls back
|
||||||
|
/// to searching for plausible original dimensions if the image was cropped.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`IdfotoError::ExtractionFailed`] if no valid secret could be recovered
|
||||||
|
/// (image was never watermarked, or was too heavily recompressed/cropped).
|
||||||
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||||
extract_with_crop_recovery(jpeg_bytes)
|
extract_with_crop_recovery(jpeg_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to extract using a specific assumed original image size and pixel offset.
|
/// Attempt to extract the secret assuming specific original image dimensions
|
||||||
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies).
|
/// and a pixel offset (for crop recovery).
|
||||||
/// `dx`/`dy` shift all block positions when reading from the actual image.
|
///
|
||||||
|
/// The block grid is computed based on `orig_w`/`orig_h` (the assumed original
|
||||||
|
/// dimensions), and then each block position is shifted by `dx`/`dy` when
|
||||||
|
/// reading from the actual (possibly cropped) image.
|
||||||
|
///
|
||||||
|
/// Uses majority voting across all copies: for each of the 256 bit positions,
|
||||||
|
/// the extracted bit from every copy votes, and the majority wins. A minimum
|
||||||
|
/// confidence threshold of 60% is required -- below that, the extraction is
|
||||||
|
/// considered unreliable and fails.
|
||||||
fn try_extract_with_layout(
|
fn try_extract_with_layout(
|
||||||
y: &YChannel,
|
y: &YChannel,
|
||||||
orig_w: usize,
|
orig_w: usize,
|
||||||
@@ -438,6 +702,7 @@ fn try_extract_with_layout(
|
|||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||||
|
|
||||||
|
// Accumulate votes for each bit position across all copies
|
||||||
let mut votes_one = vec![0usize; SECRET_BITS];
|
let mut votes_one = vec![0usize; SECRET_BITS];
|
||||||
let mut votes_total = vec![0usize; SECRET_BITS];
|
let mut votes_total = vec![0usize; SECRET_BITS];
|
||||||
|
|
||||||
@@ -447,6 +712,8 @@ fn try_extract_with_layout(
|
|||||||
if global_idx >= positions.len() {
|
if global_idx >= positions.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Apply crop offset to find the actual block position in the
|
||||||
|
// (possibly cropped) image
|
||||||
let (orig_px, orig_py) = positions[global_idx];
|
let (orig_px, orig_py) = positions[global_idx];
|
||||||
let actual_px = orig_px as isize + dx;
|
let actual_px = orig_px as isize + dx;
|
||||||
let actual_py = orig_py as isize + dy;
|
let actual_py = orig_py as isize + dy;
|
||||||
@@ -462,6 +729,7 @@ fn try_extract_with_layout(
|
|||||||
};
|
};
|
||||||
let dct = dct2_8x8(&block);
|
let dct = dct2_8x8(&block);
|
||||||
|
|
||||||
|
// Extract bits from mid-frequency coefficients and tally votes
|
||||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||||
if bit_idx >= SECRET_BITS {
|
if bit_idx >= SECRET_BITS {
|
||||||
@@ -476,7 +744,9 @@ fn try_extract_with_layout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Majority vote with confidence check
|
// Majority vote with confidence check: each bit must have >= 60% agreement
|
||||||
|
// across copies. Below that threshold, the watermark is considered too
|
||||||
|
// degraded for reliable extraction.
|
||||||
let mut result_bits = vec![0u8; SECRET_BITS];
|
let mut result_bits = vec![0u8; SECRET_BITS];
|
||||||
for i in 0..SECRET_BITS {
|
for i in 0..SECRET_BITS {
|
||||||
if votes_total[i] == 0 {
|
if votes_total[i] == 0 {
|
||||||
@@ -498,6 +768,19 @@ fn try_extract_with_layout(
|
|||||||
Ok(secret)
|
Ok(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract with automatic crop recovery.
|
||||||
|
///
|
||||||
|
/// Tries extraction in order of decreasing likelihood:
|
||||||
|
///
|
||||||
|
/// 1. **Uncropped**: assume the image has its original dimensions (most common case).
|
||||||
|
/// 2. **Width-only crop (8-pixel aligned)**: try original widths from current up to
|
||||||
|
/// +20%, stepping by 8 pixels (JPEG block alignment). Assumes right-side crop
|
||||||
|
/// (left edge unchanged, dx=0).
|
||||||
|
/// 3. **Height-only crop (8-pixel aligned)**: same strategy for vertical crops.
|
||||||
|
/// 4. **Width crop (non-aligned)**: finer 1-pixel step for non-block-aligned crops.
|
||||||
|
///
|
||||||
|
/// The search space is limited to 20% expansion in each dimension, which covers
|
||||||
|
/// the 15% crumple zone plus some margin for measurement error.
|
||||||
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||||
let y = extract_y_channel(jpeg_bytes)?;
|
let y = extract_y_channel(jpeg_bytes)?;
|
||||||
|
|
||||||
@@ -505,7 +788,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
|||||||
return Err(IdfotoError::ExtractionFailed);
|
return Err(IdfotoError::ExtractionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try assuming the image is uncropped (original size = current size)
|
// Try 1: assume the image is uncropped (original size = current size)
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
|
||||||
return Ok(secret);
|
return Ok(secret);
|
||||||
}
|
}
|
||||||
@@ -522,7 +805,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
|||||||
let max_orig_w = (y.width as f64 * 1.20) as usize;
|
let max_orig_w = (y.width as f64 * 1.20) as usize;
|
||||||
let max_orig_h = (y.height as f64 * 1.20) as usize;
|
let max_orig_h = (y.height as f64 * 1.20) as usize;
|
||||||
|
|
||||||
// Try width-only crops first (most common: crop from one side)
|
// Try 2: width-only crops, block-aligned steps (most common crop scenario)
|
||||||
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
|
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
|
||||||
// Right-side crop: dx = 0 (left edge unchanged)
|
// Right-side crop: dx = 0 (left edge unchanged)
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
||||||
@@ -530,17 +813,17 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try height-only crops
|
// Try 3: height-only crops, block-aligned steps
|
||||||
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
|
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
|
||||||
return Ok(secret);
|
return Ok(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try width crops with finer step (non-8-aligned crops)
|
// Try 4: width crops with finer step (non-8-aligned crops are rarer but possible)
|
||||||
for orig_w in (y.width..=max_orig_w).step_by(1) {
|
for orig_w in (y.width..=max_orig_w).step_by(1) {
|
||||||
if orig_w % BLOCK_SIZE == 0 {
|
if orig_w % BLOCK_SIZE == 0 {
|
||||||
continue; // already tried
|
continue; // already tried in step 2
|
||||||
}
|
}
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
||||||
return Ok(secret);
|
return Ok(secret);
|
||||||
|
|||||||
@@ -1,3 +1,37 @@
|
|||||||
|
//! # idfoto-core
|
||||||
|
//!
|
||||||
|
//! Platform-agnostic core library for the idfoto password manager.
|
||||||
|
//!
|
||||||
|
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
|
||||||
|
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
|
||||||
|
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
|
||||||
|
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
|
||||||
|
//! without any conditional compilation or platform shims.
|
||||||
|
//!
|
||||||
|
//! ## Modules
|
||||||
|
//!
|
||||||
|
//! - [`error`] -- The unified error type ([`IdfotoError`]) used across the crate.
|
||||||
|
//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated
|
||||||
|
//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer.
|
||||||
|
//! - [`entry`] -- The vault data model: [`Entry`] (full credential),
|
||||||
|
//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry
|
||||||
|
//! index that lets you list/search without decrypting every entry).
|
||||||
|
//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON
|
||||||
|
//! before encrypting, and deserialize after decrypting.
|
||||||
|
//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a
|
||||||
|
//! 256-bit secret in a JPEG image. This is the novel component that provides the
|
||||||
|
//! second authentication factor.
|
||||||
|
//!
|
||||||
|
//! ## Crypto pipeline
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
||||||
|
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
|
||||||
|
//! -> master_key (32 bytes)
|
||||||
|
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
|
||||||
|
//! -> encrypted entry/manifest
|
||||||
|
//! ```
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub use error::{IdfotoError, Result};
|
pub use error::{IdfotoError, Result};
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,72 @@
|
|||||||
|
//! Typed encryption/decryption wrappers for vault entries and manifests.
|
||||||
|
//!
|
||||||
|
//! This module bridges the gap between the raw bytes-in/bytes-out layer in
|
||||||
|
//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function
|
||||||
|
//! follows the same pattern:
|
||||||
|
//!
|
||||||
|
//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON
|
||||||
|
//! bytes with [`crate::crypto::encrypt`].
|
||||||
|
//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then
|
||||||
|
//! deserialize the resulting JSON bytes back into the typed struct.
|
||||||
|
//!
|
||||||
|
//! ## Why a single master key
|
||||||
|
//!
|
||||||
|
//! All entries and the manifest are encrypted under the same `master_key`. This is
|
||||||
|
//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults
|
||||||
|
//! (typically < 1000 entries). The security properties are equivalent: an attacker
|
||||||
|
//! who compromises the master key can decrypt everything regardless of whether
|
||||||
|
//! subkeys exist, and the vault's threat model already assumes the master key is
|
||||||
|
//! the single point of trust (protected by the two-factor KDF).
|
||||||
|
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::entry::{Entry, Manifest};
|
use crate::entry::{Entry, Manifest};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Serialize an [`Entry`] to JSON and encrypt it under the master key.
|
||||||
|
///
|
||||||
|
/// The resulting bytes are written to `entries/<id>.enc` by the CLI.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`crate::IdfotoError::Json`] if JSON serialization fails (should not happen
|
||||||
|
/// with well-formed Entry structs).
|
||||||
|
/// - [`crate::IdfotoError::Encrypt`] if the underlying AEAD operation fails.
|
||||||
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
|
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
|
||||||
let json = serde_json::to_vec(entry)?;
|
let json = serde_json::to_vec(entry)?;
|
||||||
crypto::encrypt(master_key, &json)
|
crypto::encrypt(master_key, &json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt an entry blob and deserialize it back into an [`Entry`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`crate::IdfotoError::Decrypt`] if the master key is wrong or the data is
|
||||||
|
/// tampered.
|
||||||
|
/// - [`crate::IdfotoError::Format`] if the ciphertext blob has an invalid header.
|
||||||
|
/// - [`crate::IdfotoError::Json`] if the decrypted JSON is malformed.
|
||||||
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
|
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
|
||||||
let json = crypto::decrypt(master_key, data)?;
|
let json = crypto::decrypt(master_key, data)?;
|
||||||
let entry: Entry = serde_json::from_slice(&json)?;
|
let entry: Entry = serde_json::from_slice(&json)?;
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a [`Manifest`] to JSON and encrypt it under the master key.
|
||||||
|
///
|
||||||
|
/// The resulting bytes are written to `manifest.enc` by the CLI.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Same as [`encrypt_entry`].
|
||||||
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
|
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
|
||||||
let json = serde_json::to_vec(manifest)?;
|
let json = serde_json::to_vec(manifest)?;
|
||||||
crypto::encrypt(master_key, &json)
|
crypto::encrypt(master_key, &json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt a manifest blob and deserialize it back into a [`Manifest`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Same as [`decrypt_entry`].
|
||||||
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
|
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
|
||||||
let json = crypto::decrypt(master_key, data)?;
|
let json = crypto::decrypt(master_key, data)?;
|
||||||
let manifest: Manifest = serde_json::from_slice(&json)?;
|
let manifest: Manifest = serde_json::from_slice(&json)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user