use crate::{
common::version::Version,
npk::{
dm_verity::{append_dm_verity_block, VerityHeader, BLOCK_SIZE},
manifest::{
mount::{Bind, Mount, MountOption},
Manifest,
},
},
};
use anyhow::{anyhow, bail, Context, Result};
use base64::{engine::general_purpose::STANDARD as Base64, Engine as _};
use ed25519_dalek::{Keypair, PublicKey, SecretKey, Signer, SECRET_KEY_LENGTH};
use itertools::Itertools;
use rand_core::{OsRng, RngCore};
use semver::Comparator;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fmt, fs,
io::{self, BufReader, Read, Seek, SeekFrom, Write},
os::unix::io::{AsRawFd, RawFd},
path::{Path, PathBuf},
process::Command,
str::FromStr,
};
use tempfile::NamedTempFile;
use thiserror::Error;
use zeroize::Zeroize;
use zip::ZipArchive;
use super::VERSION;
pub const MKSQUASHFS: &str = "mksquashfs";
pub const UNSQUASHFS: &str = "unsquashfs";
pub const FS_IMG_NAME: &str = "fs.img";
pub const MANIFEST_NAME: &str = "manifest.yaml";
pub const SIGNATURE_NAME: &str = "signature.yaml";
pub const NPK_EXT: &str = "npk";
const MKSQUASHFS_MAJOR_VERSION_MIN: u64 = 4;
const MKSQUASHFS_MINOR_VERSION_MIN: u64 = 1;
type Zip<R> = ZipArchive<R>;
#[derive(Error, Debug)]
#[error(transparent)]
pub struct Error(#[from] anyhow::Error);
#[derive(Debug, Serialize, Deserialize)]
pub struct Meta {
pub version: Version,
}
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct Hashes {
pub meta_hash: String,
pub manifest_hash: String,
pub fs_verity_hash: String,
pub fs_verity_offset: u64,
}
impl FromStr for Hashes {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct MetaHash {
hash: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ManifestHash {
hash: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct FsHash {
verity_hash: String,
verity_offset: u64,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct SerdeHashes {
meta: MetaHash,
#[serde(rename = "manifest.yaml")]
manifest: ManifestHash,
#[serde(rename = "fs.img")]
fs: FsHash,
}
let hashes = serde_yaml::from_str::<SerdeHashes>(s).context("failed to parse hashes")?;
Ok(Hashes {
meta_hash: hashes.meta.hash,
manifest_hash: hashes.manifest.hash,
fs_verity_hash: hashes.fs.verity_hash,
fs_verity_offset: hashes.fs.verity_offset,
})
}
}
#[derive(Debug)]
pub struct Npk<R> {
meta: Meta,
file: R,
manifest: Manifest,
fs_img_offset: u64,
fs_img_size: u64,
verity_header: Option<VerityHeader>,
hashes: Option<Hashes>,
}
impl<R: Read + Seek> Npk<R> {
pub fn from_reader(reader: R, key: Option<&PublicKey>) -> Result<Self, Error> {
let mut zip = Zip::new(reader).context("archive error")?;
let version_request = semver::VersionReq {
comparators: vec![Comparator {
op: semver::Op::GreaterEq,
major: VERSION.major,
minor: Some(VERSION.minor),
patch: None,
pre: semver::Prerelease::default(),
}],
};
let hashes = if let Some(key) = key {
let hashes = hashes(&mut zip, key)?;
Some(hashes)
} else {
None
};
let meta = meta(&mut zip, hashes.as_ref())?;
let version = &meta.version;
if !version_request.matches(&(version.into())) {
return Err(anyhow!(
"NPK version format {} doesn't match required version {}",
meta.version,
version_request
)
.into());
}
let manifest = manifest(&mut zip, hashes.as_ref())?;
let (fs_img_offset, fs_img_size) = {
let fs_img = &zip
.by_name(FS_IMG_NAME)
.with_context(|| format!("failed to locate {} in ZIP file", &FS_IMG_NAME))?;
(fs_img.data_start(), fs_img.size())
};
let mut file = zip.into_inner();
let verity_header = match &hashes {
Some(hs) => {
file.seek(SeekFrom::Start(fs_img_offset + hs.fs_verity_offset))
.with_context(|| {
format!("{} too small to extract verity header", &FS_IMG_NAME)
})?;
Some(VerityHeader::from_bytes(&mut file).context("failed to read verity header")?)
}
None => None,
};
Ok(Self {
meta,
file,
manifest,
fs_img_offset,
fs_img_size,
verity_header,
hashes,
})
}
pub fn from_path(
npk: &Path,
key: Option<&PublicKey>,
) -> Result<Npk<BufReader<fs::File>>, Error> {
let npk_file =
fs::File::open(npk).with_context(|| format!("failed to open {}", npk.display()))?;
Npk::from_reader(BufReader::new(npk_file), key)
}
pub fn meta(&self) -> &Meta {
&self.meta
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn version(&self) -> &Version {
&self.meta.version
}
pub fn fsimg_offset(&self) -> u64 {
self.fs_img_offset
}
pub fn fsimg_size(&self) -> u64 {
self.fs_img_size
}
pub fn hashes(&self) -> Option<&Hashes> {
self.hashes.as_ref()
}
pub fn verity_header(&self) -> Option<&VerityHeader> {
self.verity_header.as_ref()
}
}
impl AsRawFd for Npk<BufReader<fs::File>> {
fn as_raw_fd(&self) -> RawFd {
self.file.get_ref().as_raw_fd()
}
}
fn meta<R: Read + Seek>(zip: &mut Zip<R>, hashes: Option<&Hashes>) -> Result<Meta> {
let content = zip.comment();
if let Some(Hashes { meta_hash, .. }) = &hashes {
let expected_hash = hex::decode(meta_hash).context("failed to parse manifest hash")?;
let actual_hash = Sha256::digest(content);
if expected_hash != actual_hash.as_slice() {
bail!(
"invalid meta hash (expected={} actual={})",
meta_hash,
hex::encode(actual_hash)
);
}
}
serde_yaml::from_slice(zip.comment()).context("comment malformed")
}
fn hashes<R: Read + Seek>(zip: &mut Zip<R>, key: &PublicKey) -> Result<Hashes, Error> {
let signature_content = read_to_string(zip, SIGNATURE_NAME)?;
let mut documents = signature_content.split("---");
let hashes_str = documents
.next()
.ok_or_else(|| anyhow!("malformed signatures file"))?;
let hashes = Hashes::from_str(hashes_str)?;
let signature = documents
.next()
.ok_or_else(|| anyhow!("malformed signatures file"))?;
let signature = decode_signature(signature)?;
key.verify_strict(hashes_str.as_bytes(), &signature)
.context("invalid signature")?;
Ok(hashes)
}
fn manifest<R: Read + Seek>(zip: &mut Zip<R>, hashes: Option<&Hashes>) -> Result<Manifest> {
let content = read_to_string(zip, MANIFEST_NAME)?;
if let Some(Hashes { manifest_hash, .. }) = &hashes {
let expected_hash = hex::decode(manifest_hash).context("failed to parse manifest hash")?;
let actual_hash = Sha256::digest(content.as_bytes());
if expected_hash != actual_hash.as_slice() {
bail!(
"invalid manifest hash (expected={} actual={})",
manifest_hash,
hex::encode(actual_hash)
);
}
}
Manifest::from_str(&content).context("failed to parse manifest")
}
fn read_to_string<R: Read + Seek>(zip: &mut Zip<R>, name: &str) -> Result<String, Error> {
let mut file = zip
.by_name(name)
.with_context(|| format!("failed to locate {name} in ZIP file"))?;
let mut content = String::with_capacity(file.size() as usize);
file.read_to_string(&mut content)
.with_context(|| format!("failed to read from {name}"))?;
Ok(content)
}
fn decode_signature(s: &str) -> Result<ed25519_dalek::Signature> {
#[allow(unused)]
#[derive(Debug, Deserialize)]
struct SerdeSignature {
signature: String,
}
let de: SerdeSignature = serde_yaml::from_str::<SerdeSignature>(s)
.context("failed to parse signature YAML format")?;
let signature = Base64
.decode(de.signature)
.context("failed to decode signature base 64 format")?;
ed25519_dalek::Signature::from_bytes(&signature)
.context("failed to parse signature ed25519 format")
}
struct Builder<'a> {
root: &'a Path,
manifest: &'a Manifest,
key: Option<&'a Path>,
squashfs_options: SquashfsOptions,
}
impl<'a> Builder<'a> {
fn new(root: &'a Path, manifest: &'a Manifest) -> Builder<'a> {
Builder {
root,
manifest,
key: None,
squashfs_options: SquashfsOptions::default(),
}
}
fn key(mut self, key: &'a Path) -> Builder<'a> {
self.key = Some(key);
self
}
fn squashfs_opts(mut self, opts: &'a SquashfsOptions) -> Builder<'a> {
self.squashfs_options = opts.clone();
self
}
fn build<W: Write + Seek>(&self, writer: W) -> Result<()> {
let tmp = tempfile::TempDir::new().context("failed to create temporary directory")?;
let meta = &Meta { version: VERSION };
let fsimg = tmp.path().join(FS_IMG_NAME);
create_squashfs_img(self.manifest, self.root, &fsimg, &self.squashfs_options)?;
if let Some(key) = &self.key {
let signature = signature(key, meta, &fsimg, self.manifest)?;
write_npk(writer, meta, self.manifest, &fsimg, Some(&signature))
} else {
write_npk(writer, meta, self.manifest, &fsimg, None)
}
}
}
#[derive(Clone, Debug)]
#[allow(missing_docs)]
pub enum Compression {
Gzip,
Lzma,
Lzo,
Xz,
Zstd,
}
impl fmt::Display for Compression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Compression::Gzip => write!(f, "gzip"),
Compression::Lzma => write!(f, "lzma"),
Compression::Lzo => write!(f, "lzo"),
Compression::Xz => write!(f, "xz"),
Compression::Zstd => write!(f, "zstd"),
}
}
}
impl FromStr for Compression {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"gzip" => Ok(Compression::Gzip),
"lzma" => Ok(Compression::Lzma),
"lzo" => Ok(Compression::Lzo),
"xz" => Ok(Compression::Xz),
"zstd" => Ok(Compression::Zstd),
_ => Err(anyhow!("invalid compression algorithm").into()),
}
}
}
#[derive(Clone, Debug)]
pub struct SquashfsOptions {
pub mksquashfs: PathBuf,
pub compression: Compression,
pub block_size: Option<u32>,
}
impl Default for SquashfsOptions {
fn default() -> Self {
SquashfsOptions {
compression: Compression::Gzip,
block_size: None,
mksquashfs: PathBuf::from(MKSQUASHFS),
}
}
}
pub fn pack(
manifest: &Path,
root: &Path,
out: &Path,
key: Option<&Path>,
) -> Result<PathBuf, Error> {
pack_with(manifest, root, out, key, &SquashfsOptions::default())
}
pub fn pack_with(
manifest: &Path,
root: &Path,
out: &Path,
key: Option<&Path>,
squashfs_opts: &SquashfsOptions,
) -> Result<PathBuf, Error> {
let manifest = read_manifest(manifest)?;
pack_with_manifest(&manifest, root, out, key, squashfs_opts)
}
pub fn pack_with_manifest(
manifest: &Manifest,
root: &Path,
out: &Path,
key: Option<&Path>,
squashfs_opts: &SquashfsOptions,
) -> Result<PathBuf, Error> {
let name = manifest.name.clone();
let version = manifest.version.clone();
let mut builder = Builder::new(root, manifest);
if let Some(key) = key {
builder = builder.key(key);
}
builder = builder.squashfs_opts(squashfs_opts);
let mut dest = out.to_path_buf();
if Path::is_dir(out) {
dest.push(format!("{}-{}.", &name, &version));
dest.set_extension(NPK_EXT);
}
let npk = fs::File::create(&dest)
.with_context(|| format!("failed to create NPK: '{}'", &dest.display()))?;
builder.build(npk)?;
Ok(dest)
}
pub fn unpack(npk: &Path, out: &Path) -> Result<(), Error> {
unpack_with(npk, out, Path::new(UNSQUASHFS))
}
pub fn unpack_with(npk: &Path, out: &Path, unsquashfs: &Path) -> Result<(), Error> {
let mut zip = open(npk)?;
zip.extract(out)
.with_context(|| format!("failed to extract NPK to '{}'", &out.display()))?;
let fsimg = out.join(FS_IMG_NAME);
unpack_squashfs(&fsimg, out, unsquashfs)?;
Ok(())
}
pub fn generate_key(name: &str, out: &Path) -> Result<(), Error> {
fn assume_non_existing(path: &Path) -> anyhow::Result<()> {
if path.exists() {
bail!("file '{}' already exists", &path.display())
} else {
Ok(())
}
}
fn write(data: &[u8], path: &Path) -> Result<(), Error> {
let mut file = fs::File::create(path)
.with_context(|| format!("failed to create '{}'", path.display()))?;
file.write_all(data)
.with_context(|| format!("failed to write to '{}'", &path.display()))?;
Ok(())
}
let mut secret_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut secret_key_bytes);
let secret_key = secret_key(secret_key_bytes)?;
let public_key = ed25519_dalek::PublicKey::from(&secret_key);
let secret_key_file = out.join(name).with_extension("key");
let public_key_file = out.join(name).with_extension("pub");
assume_non_existing(&public_key_file)?;
assume_non_existing(&secret_key_file)?;
write(&secret_key.to_bytes(), &secret_key_file)?;
write(&public_key.to_bytes(), &public_key_file)?;
Ok(())
}
fn read_manifest(path: &Path) -> Result<Manifest> {
let file =
fs::File::open(path).with_context(|| format!("failed to open '{}'", &path.display()))?;
Manifest::from_reader(&file).with_context(|| format!("failed to parse '{}'", &path.display()))
}
fn read_keypair(key_file: &Path) -> Result<Keypair, Error> {
let mut secret_key_bytes = [0u8; SECRET_KEY_LENGTH];
fs::File::open(key_file)
.with_context(|| format!("failed to open '{}'", &key_file.display()))?
.read_exact(&mut secret_key_bytes)
.with_context(|| format!("failed to read key data from '{}'", &key_file.display()))?;
let secret_key = secret_key(secret_key_bytes)?;
let public_key = PublicKey::from(&secret_key);
Ok(Keypair {
secret: secret_key,
public: public_key,
})
}
fn secret_key(mut bytes: [u8; SECRET_KEY_LENGTH]) -> Result<SecretKey> {
let secret_key =
SecretKey::from_bytes(bytes.as_slice()).context("failed to read secret key")?;
bytes.zeroize(); Ok(secret_key)
}
fn hashes_yaml(
meta_hash: &[u8],
manifest_hash: &[u8],
verity_hash: &[u8],
verity_offset: u64,
) -> String {
format!(
"{}:\n hash: {:02x?}\n\
{}:\n hash: {:02x?}\n\
{}:\n verity-hash: {:02x?}\n verity-offset: {}\n",
"meta",
meta_hash.iter().format(""),
&MANIFEST_NAME,
manifest_hash.iter().format(""),
&FS_IMG_NAME,
verity_hash.iter().format(""),
verity_offset
)
}
fn signature(key: &Path, meta: &Meta, fsimg: &Path, manifest: &Manifest) -> Result<String, Error> {
let meta_hash =
Sha256::digest(serde_yaml::to_string(&meta).context("failed to encode metadata")?);
let manifest_hash = Sha256::digest(manifest.to_string().as_bytes());
let fsimg_size = fs::metadata(fsimg)
.with_context(|| format!("failed to read file size: '{}'", &fsimg.display()))?
.len();
let fsimg_hash: &[u8] = &append_dm_verity_block(fsimg, fsimg_size)
.context("failed to calculate verity root hash")?;
let hashes_yaml = hashes_yaml(&meta_hash, &manifest_hash, fsimg_hash, fsimg_size);
let key_pair = read_keypair(key)?;
let signature = key_pair.sign(hashes_yaml.as_bytes());
let signature_base64 = Base64.encode(signature);
let signature_yaml = { format!("{}---\nsignature: {}", &hashes_yaml, &signature_base64) };
Ok(signature_yaml)
}
fn pseudo_files(manifest: &Manifest) -> Result<NamedTempFile, Error> {
let uid = manifest.uid;
let gid = manifest.gid;
let pseudo_directory = |dir: &Path, mode: u16| -> Vec<String> {
let mut pseudos = Vec::new();
let mut p = PathBuf::from("/");
for d in dir.iter().skip(1) {
p.push(d);
pseudos.push(format!("{} d {} {} {}", p.display(), mode, uid, gid));
}
pseudos
};
let pseudos = manifest
.mounts
.iter()
.sorted_by(|(a, _), (b, _)| a.cmp(b))
.flat_map(|(target, mount)| {
match mount {
Mount::Bind(Bind { options: flags, .. }) => {
let mode = if flags.contains(&MountOption::Rw) {
755
} else {
555
};
pseudo_directory(target.as_ref(), mode)
}
Mount::Persist => pseudo_directory(target.as_ref(), 755),
Mount::Proc | Mount::Sysfs => pseudo_directory(target.as_ref(), 444),
Mount::Resource { .. } => pseudo_directory(target.as_ref(), 555),
Mount::Sockets => pseudo_directory(target.as_ref(), 755),
Mount::Tmpfs { .. } => pseudo_directory(target.as_ref(), 755),
Mount::Dev => {
let mut pseudos = pseudo_directory(target.as_ref(), 755);
for (dev, major, minor) in &[
("full", 1, 7),
("null", 1, 3),
("random", 1, 8),
("tty", 5, 0),
("urandom", 1, 9),
("zero", 1, 5),
] {
let target: &Path = target.as_ref();
let target = target.join(dev).display().to_string();
pseudos.push(format!(
"{} c {} {} {} {} {}",
target, 666, uid, gid, major, minor
));
}
pseudos.push(format!("/proc/self/fd d 777 {uid} {gid}"));
for (link, name) in &[
("/proc/self/fd", "fd"),
("/proc/self/fd/0", "stdin"),
("/proc/self/fd/1", "stdout"),
("/proc/self/fd/2", "stderr"),
] {
let target: &Path = target.as_ref();
let target = target.join(name).display().to_string();
pseudos.push(format!("{} s {} {} {} {}", target, 777, uid, gid, link,));
}
pseudos
}
}
})
.collect::<Vec<String>>();
let mut pseudo_file_entries =
NamedTempFile::new().context("failed to create temporary file")?;
pseudos.iter().try_for_each(|l| {
writeln!(pseudo_file_entries, "{l}").context("failed to create pseudo files")
})?;
Ok(pseudo_file_entries)
}
fn create_squashfs_img(
manifest: &Manifest,
root: &Path,
image: &Path,
squashfs_opts: &SquashfsOptions,
) -> Result<()> {
let pseudo_files = pseudo_files(manifest)?;
let mksquashfs = &squashfs_opts.mksquashfs;
if !root.exists() {
bail!("Root directory '{}' does not exist", &root.display());
}
let stdout = String::from_utf8(
Command::new(mksquashfs)
.arg("-version")
.output()
.with_context(|| format!("failed to execute '{}'", mksquashfs.display()))?
.stdout,
)
.context("failed to parse mksquashfs output")?;
let first_line = stdout.lines().next().unwrap_or_default();
let mut major_minor = first_line.split(' ').nth(2).unwrap_or_default().split('.');
let major = major_minor
.next()
.unwrap_or_default()
.parse::<u64>()
.unwrap_or_default();
let minor = major_minor.next().unwrap_or_default();
let minor = minor.parse::<u64>().unwrap_or_else(|_| {
minor
.split(|c: char| !c.is_numeric())
.next()
.unwrap_or_default()
.parse::<u64>()
.unwrap_or_default()
});
let actual = Version::new(major, minor, 0);
let required = Version::new(
MKSQUASHFS_MAJOR_VERSION_MIN,
MKSQUASHFS_MINOR_VERSION_MIN,
0,
);
if actual < required {
bail!(
"Detected mksquashfs version {}.{} is too old. The required minimum version is {}.{}",
major,
minor,
MKSQUASHFS_MAJOR_VERSION_MIN,
MKSQUASHFS_MINOR_VERSION_MIN
);
}
let mut cmd = Command::new(mksquashfs);
cmd.arg(&root.display().to_string())
.arg(&image.display().to_string())
.arg("-no-progress")
.arg("-comp")
.arg(squashfs_opts.compression.to_string())
.arg("-info")
.arg("-force-uid")
.arg(manifest.uid.to_string())
.arg("-force-gid")
.arg(manifest.gid.to_string())
.arg("-pf")
.arg(pseudo_files.path());
if let Some(block_size) = squashfs_opts.block_size {
cmd.arg("-b").arg(format!("{block_size}"));
}
cmd.output()
.with_context(|| format!("failed to execute '{}'", mksquashfs.display()))?;
if !image.exists() {
bail!(
"'{}' failed to create '{}'",
mksquashfs.display(),
&image.display()
);
}
Ok(())
}
fn unpack_squashfs(image: &Path, out: &Path, unsquashfs: &Path) -> Result<()> {
let squashfs_root = out.join("squashfs-root");
if !image.exists() {
bail!("Squashfs image '{}' does not exist", &image.display());
}
let mut cmd = Command::new(unsquashfs);
cmd.arg("-dest")
.arg(&squashfs_root.display().to_string())
.arg(&image.display().to_string());
cmd.output()
.with_context(|| format!("Error while executing '{}'", unsquashfs.display(),))?;
Ok(())
}
fn write_npk<W: Write + Seek>(
npk: W,
meta: &Meta,
manifest: &Manifest,
fsimg: &Path,
signature: Option<&str>,
) -> Result<()> {
let mut fsimg =
fs::File::open(fsimg).with_context(|| format!("failed to open '{}'", &fsimg.display()))?;
let options =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
let manifest_string =
serde_yaml::to_string(&manifest).context("failed to serialize manifest")?;
let meta_string = serde_yaml::to_string(&meta).context("failed to serialize meta")?;
let mut zip = zip::ZipWriter::new(npk);
zip.set_comment(&meta_string);
if let Some(signature) = signature {
zip.start_file(SIGNATURE_NAME, options)?;
zip.write_all(signature.as_bytes())
.context("failed to write signature to NPK")?;
}
zip.start_file(MANIFEST_NAME, options)
.context("failed to write manifest to NPK")?;
zip.write_all(manifest_string.as_bytes())
.context("failed to convert manifest to NPK")?;
zip.start_file_aligned(FS_IMG_NAME, options, BLOCK_SIZE as u16)
.context("Could create aligned zip-file")?;
io::copy(&mut fsimg, &mut zip)
.context("failed to write the filesystem image to the archive")?;
Ok(())
}
fn open(path: &Path) -> Result<Zip<BufReader<fs::File>>> {
let file =
fs::File::open(path).with_context(|| format!("failed to open '{}'", &path.display()))?;
ZipArchive::new(BufReader::new(file))
.with_context(|| format!("failed to parse ZIP format: '{}'", &path.display()))
}