Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
366f1d7
Add disk list and disk format subcommands (MSG 102, MSG 103)
lorek123 Feb 4, 2026
ecf1f16
Add E1 replay/download support with VFR-correct MP4 muxing
lorek123 Feb 12, 2026
5a776a0
Embed AI detection tags and recording metadata in MP4 output
lorek123 Feb 12, 2026
07c5b43
Fix MP4 metadata: use mp4mux TagSetter instead of appsrc event
lorek123 Feb 12, 2026
1ab21e3
Search all record types by default and add --ai-filter for file listing
lorek123 Feb 12, 2026
8f2b121
Add alarm video search (MSG 175) for server-side AI/event filtering
lorek123 Feb 12, 2026
2425804
Fix record_type regression, add error handling, clean up logging
lorek123 Feb 14, 2026
856c498
Fix Argus 2 replay: decrypt binary payload for non-E1 cameras, guard …
lorek123 Apr 3, 2026
ed30ca4
Fix MP4 duration and mux BcMedia streams to proper MP4 on download
lorek123 Apr 3, 2026
9234f16
Fix Argus 2 decrypt: remove incorrect AES plaintext bypass
lorek123 Apr 3, 2026
63e51aa
Add replay download-by-name subcommand (MSG 8 / NET_DOWNLOAD_V20)
lorek123 Apr 4, 2026
49be6b9
Fix replay decrypt for Aes (non-FullAes) cameras like Argus 2
lorek123 Apr 4, 2026
8510163
Fix FullAes replay decrypt: skip AES on packets without encryptLen
lorek123 Apr 4, 2026
7d10320
Fix infinite loop in replay fallback and zero-size output files
lorek123 Apr 6, 2026
1576a6a
Support H.265/HEVC mainStream replay (Argus 3, Argus PT)
lorek123 Apr 6, 2026
538032f
Detect actual video codec from NAL bytes, overriding BcMedia header
lorek123 Apr 7, 2026
5c6f5f0
List both mainStream and subStream files by default in 'replay files'
lorek123 Apr 8, 2026
c2d9621
Paginate MSG 15 file list to return all files, not just first 40
lorek123 Apr 12, 2026
c534573
Pass camera UID in MSG 8 download-by-name payload
lorek123 Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,46 @@ jobs:
env:
DOCKER_BUILDKIT: 1

dev_release:
name: Dev Pre-release
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/feature/disk-subcommand' && github.event_name == 'push'
permissions:
contents: write
needs:
- "cross"
- "native"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare zips
run: |
cd artifacts
for d in */; do
chmod +x "${d}/neolink"* 2>/dev/null || true
zip -r "../${d%/}.zip" "${d}"
done
cd ..
- name: Publish rolling pre-release
uses: softprops/action-gh-release@v1
with:
tag_name: dev-feature-disk-subcommand
name: "Dev build: feature/disk-subcommand"
body: |
Automated build from the `feature/disk-subcommand` branch (commit ${{ github.sha }}).
Includes disk management, SD card replay/download, alarm search, and Argus 2 fix.

**This is a test build — not for production use.**

Download the zip for your platform, extract, and run `neolink`.
draft: false
prerelease: true
files: "*.zip"

create_release:
name: Create Release
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ regex = "1.7.3"
rumqttc = "0.24.0"
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"
tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "io-util", "tracing"] }
tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "io-util", "tracing", "signal"] }
tokio-stream = "0.1.12"
tokio-util = { version = "0.7.7", features = ["full", "tracing"] }
toml = "0.8.2"
Expand Down
23 changes: 18 additions & 5 deletions crates/core/src/bc/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use crate::bc::model::*;
use crate::bc::xml::*;
use crate::{Credentials, Error, Result};
use bytes::BytesMut;
use bytes::{Buf, BytesMut};
use nom::AsBytes;
use tokio_util::codec::{Decoder, Encoder};

Expand Down Expand Up @@ -84,12 +84,25 @@ impl Decoder for BcCodex {
}

fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>> {
// trace!("Decoding: {:X?}", src);
let bc = Bc::deserialize(&self.context, src);
// trace!("As: {:?}", bc);
let bc = match bc {
const MAGIC_LE: [u8; 4] = [0xf0, 0xde, 0xbc, 0x0a];
const MAGIC_REV_LE: [u8; 4] = [0xa0, 0xcb, 0xed, 0x0f];
// Use the current read window (chunk); after a previous decode the Buf cursor advanced.
let chunk = src.chunk();
// Strip 4-byte prefix before BC frame. E1 sends 4 bytes (sometimes zeros, sometimes not) then magic.
if chunk.len() >= 8 && (chunk[4..8] == MAGIC_LE || chunk[4..8] == MAGIC_REV_LE) {
src.advance(4);
}
let bc = match Bc::deserialize(&self.context, src) {
Ok(bc) => bc,
Err(Error::NomIncomplete(_)) => return Ok(None),
Err(Error::NomError(ref msg)) if msg.contains("Magic invalid") => {
let chunk = src.chunk();
// Fewer than 8 bytes: might be partial prefix or partial frame, wait for more
if chunk.len() < 8 {
return Ok(None);
}
return Err(Error::NomError(msg.clone()));
}
Err(e) => return Err(e),
};
// Update context
Expand Down
180 changes: 161 additions & 19 deletions crates/core/src/bc/crypto.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,62 @@
use aes::{
cipher::{AsyncStreamCipher, KeyIvInit},
cipher::{AsyncStreamCipher, BlockEncrypt, KeyInit, KeyIvInit},
Aes128,
};
use cfb_mode::{Decryptor, Encryptor};

type Aes128CfbEnc = Encryptor<Aes128>;
type Aes128CfbDec = Decryptor<Aes128>;

/// Decrypt with explicit CFB state (iv, num) and return updated state.
/// Not used for replay: SDK resets num=0 every packet (per-packet fresh IV only).
/// num is in 0..16 (bytes already consumed from current keystream block).
pub fn decrypt_cfb_with_state(
key: &[u8; 16],
iv: &mut [u8; 16],
num: &mut u8,
ciphertext: &[u8],
) -> Vec<u8> {
use aes::cipher::generic_array::GenericArray;
use aes::cipher::typenum::U16;
let cipher = Aes128::new_from_slice(key).expect("key length");
let mut out = Vec::with_capacity(ciphertext.len());
let mut i = 0;
while i < ciphertext.len() {
let mut block: GenericArray<u8, U16> = GenericArray::clone_from_slice(iv);
cipher.encrypt_block(&mut block);
let keystream = block.as_slice();
let n = (16 - (*num as usize)).min(ciphertext.len() - i);
for j in 0..n {
out.push(ciphertext[i + j] ^ keystream[(*num as usize) + j]);
}
// CFB feedback: iv is the last 16 bytes of ciphertext; shift and append each new ct byte
for k in 0..n {
iv[0] = iv[1];
iv[1] = iv[2];
iv[2] = iv[3];
iv[3] = iv[4];
iv[4] = iv[5];
iv[5] = iv[6];
iv[6] = iv[7];
iv[7] = iv[8];
iv[8] = iv[9];
iv[9] = iv[10];
iv[10] = iv[11];
iv[11] = iv[12];
iv[12] = iv[13];
iv[13] = iv[14];
iv[14] = iv[15];
iv[15] = ciphertext[i + k];
}
i += n;
*num = (*num as usize + n) as u8 % 16;
}
out
}

const XML_KEY: [u8; 8] = [0x1F, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0xFF];
const IV: &[u8] = b"0123456789abcdef";
/// Default IV when no custom IV is set (SDK: BaichuanEncryptor uses this when *(this+0x1c) != 0x10).
const IV: [u8; 16] = *b"0123456789abcdef";

/// These are the encyption modes supported by the camera
///
Expand All @@ -22,18 +70,18 @@ pub enum EncryptionProtocol {
/// Latest cameras/firmwares use Aes with the key derived from
/// the camera's password and the negotiated NONCE
Aes {
/// The encryptor
enc: Aes128CfbEnc,
/// The decryptor
dec: Aes128CfbDec,
/// AES-128 key (16 bytes)
key: [u8; 16],
/// Master IV (16 bytes) - reset to this value for each packet
iv: [u8; 16],
},
/// Same as Aes but the media stream is also encrypted and not just
/// the control commands
FullAes {
/// The encryptor
enc: Aes128CfbEnc,
/// The decryptor
dec: Aes128CfbDec,
/// AES-128 key (16 bytes)
key: [u8; 16],
/// Master IV (16 bytes) - reset to this value for each packet
iv: [u8; 16],
},
}

Expand All @@ -49,15 +97,15 @@ impl EncryptionProtocol {
/// Helper to make aes
pub fn aes(key: [u8; 16]) -> Self {
EncryptionProtocol::Aes {
enc: Aes128CfbEnc::new(key.as_slice().into(), IV.into()),
dec: Aes128CfbDec::new(key.as_slice().into(), IV.into()),
key,
iv: IV,
}
}
/// Helper to make full aes
pub fn full_aes(key: [u8; 16]) -> Self {
EncryptionProtocol::FullAes {
enc: Aes128CfbEnc::new(key.as_slice().into(), IV.into()),
dec: Aes128CfbDec::new(key.as_slice().into(), IV.into()),
key,
iv: IV,
}
}

Expand All @@ -72,16 +120,34 @@ impl EncryptionProtocol {
.map(|(key, i)| *i ^ key ^ (offset as u8))
.collect()
}
EncryptionProtocol::Aes { dec, .. } | EncryptionProtocol::FullAes { dec, .. } => {
EncryptionProtocol::Aes { key, iv } | EncryptionProtocol::FullAes { key, iv } => {
// AES decryption

// CRITICAL: Based on Ghidra analysis, the app resets CFB state per packet.
// Each packet starts fresh with Master IV and num=0.
// We create a new decryptor for each packet to match this behavior.
// CFB state is still preserved WITHIN a packet (for multi-block packets).
let decryptor = Aes128CfbDec::new(key.as_slice().into(), iv.as_slice().into());
let mut decrypted = buf.to_vec();
dec.clone().decrypt(&mut decrypted);
decryptor.decrypt(&mut decrypted);
decrypted
}
}
}

/// Decrypt with an explicit IV (e.g. per-packet IV from first 16 bytes of payload).
/// Only supported for Aes/FullAes; returns None for other protocols.
pub fn decrypt_with_iv(&self, packet_iv: &[u8; 16], buf: &[u8]) -> Option<Vec<u8>> {
match self {
EncryptionProtocol::Aes { key, .. } | EncryptionProtocol::FullAes { key, .. } => {
let decryptor = Aes128CfbDec::new(key.as_slice().into(), packet_iv.as_slice().into());
let mut decrypted = buf.to_vec();
decryptor.decrypt(&mut decrypted);
Some(decrypted)
}
_ => None,
}
}

/// Encrypt the data, offset comes from the header of the packet
pub fn encrypt(&self, offset: u32, buf: &[u8]) -> Vec<u8> {
match self {
Expand All @@ -93,10 +159,15 @@ impl EncryptionProtocol {
// Encrypt is the same as decrypt
self.decrypt(offset, buf)
}
EncryptionProtocol::Aes { enc, .. } | EncryptionProtocol::FullAes { enc, .. } => {
EncryptionProtocol::Aes { key, iv } | EncryptionProtocol::FullAes { key, iv } => {
// AES encryption
// CRITICAL: Based on Ghidra analysis, the app resets CFB state per packet.
// Each packet starts fresh with Master IV and num=0.
// We create a new encryptor for each packet to match this behavior.
// CFB state is still preserved WITHIN a packet (for multi-block packets).
let encryptor = Aes128CfbEnc::new(key.as_slice().into(), iv.as_slice().into());
let mut encrypted = buf.to_vec();
enc.clone().encrypt(&mut encrypted);
encryptor.encrypt(&mut encrypted);
encrypted
}
}
Expand All @@ -120,3 +191,74 @@ fn test_xml_crypto_roundtrip() {
let encrypted = EncryptionProtocol::BCEncrypt.decrypt(0, &decrypted[..]);
assert_eq!(encrypted, &zeros[..]);
}

#[test]
fn test_aes_cfb_per_packet_reset() {
// Test that CFB state resets per packet (matches app behavior from Ghidra analysis)
// Each packet should decrypt independently, starting fresh with Master IV
let key = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10];

let encryptor = EncryptionProtocol::aes(key);
let decryptor = EncryptionProtocol::aes(key);

// Encrypt multiple packets sequentially (each starts fresh)
let packet1 = b"Packet 1 data";
let packet2 = b"Packet 2 data";
let packet3 = b"Packet 3 data";

let enc1 = encryptor.encrypt(0, packet1);
let enc2 = encryptor.encrypt(0, packet2);
let enc3 = encryptor.encrypt(0, packet3);

// Decrypt packets sequentially (each starts fresh)
let dec1 = decryptor.decrypt(0, &enc1);
let dec2 = decryptor.decrypt(0, &enc2);
let dec3 = decryptor.decrypt(0, &enc3);

// Verify all packets decrypt correctly (each packet is independent)
assert_eq!(dec1, packet1, "Packet 1 should decrypt correctly with per-packet reset");
assert_eq!(dec2, packet2, "Packet 2 should decrypt correctly with per-packet reset");
assert_eq!(dec3, packet3, "Packet 3 should decrypt correctly with per-packet reset");
}

#[test]
fn test_aes_cfb_within_packet() {
// Test that CFB state is preserved WITHIN a packet (for multi-block packets)
// A single large packet should decrypt correctly, even though packets are independent
let key = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10];

let encryptor = EncryptionProtocol::aes(key);
let decryptor = EncryptionProtocol::aes(key);

// Large packet (multiple blocks - ~62 blocks for 1000 bytes)
let large_packet = vec![0xAA; 1000];

let encrypted = encryptor.encrypt(0, &large_packet);
let decrypted = decryptor.decrypt(0, &encrypted);

assert_eq!(decrypted, large_packet, "Large packet should decrypt correctly with CFB state preserved within packet");
}

#[test]
fn test_full_aes_cfb_per_packet_reset() {
// Test FullAes variant - per-packet reset behavior
let key = [0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80,
0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0, 0x00];

let encryptor = EncryptionProtocol::full_aes(key);
let decryptor = EncryptionProtocol::full_aes(key);

let packet1 = b"FullAes packet 1";
let packet2 = b"FullAes packet 2";

let enc1 = encryptor.encrypt(0, packet1);
let enc2 = encryptor.encrypt(0, packet2);

let dec1 = decryptor.decrypt(0, &enc1);
let dec2 = decryptor.decrypt(0, &enc2);

assert_eq!(dec1, packet1, "FullAes packet 1 should decrypt correctly with per-packet reset");
assert_eq!(dec2, packet2, "FullAes packet 2 should decrypt correctly with per-packet reset");
}
Loading