summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 08:58:04 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 08:58:04 -0500
commitb7b163a4d34f486663e72950625ad03fbf022f73 (patch)
tree2e7fd4383f164f15ab8a29524384076ec3a923d2
parente0bfd8bd4991c898d008b433977a922e843ab354 (diff)
Export readable wrapped chat files with legacy compatibilityHEADmain
-rw-r--r--README.txt6
-rw-r--r--crates/chat-crypto/README.txt2
-rw-r--r--crates/chat-crypto/src/application.rs115
-rw-r--r--crates/chat-server/tests/browser.mjs7
-rw-r--r--docs/CHAT-APPLICATION.txt21
-rw-r--r--docs/CHAT-PRODUCTION.txt4
6 files changed, 134 insertions, 21 deletions
diff --git a/README.txt b/README.txt
index f2cdd1d..811ddc8 100644
--- a/README.txt
+++ b/README.txt
@@ -78,15 +78,15 @@ CHECKS AND LICENSES
Detailed manual backup, recovery, deployment, and host setup instructions
are in docs/OPERATIONS.txt. Read docs/POLICY.txt before changing conventions.
-EXPERIMENTAL CHAT BRANCH
+EXPERIMENTAL CHAT
- feature/private-chat contains the experimental Rust/WASI chat application. Start with
+ main includes the experimental Rust/WASI chat application. Start with
docs/CHAT-APPLICATION.txt for setup, protected files, tests, backup/restore,
source licensing, and security limitations. cargo stack chat --init appends
chat configuration to the existing single root .env; cargo stack chat runs
the direct local service. docs/CHAT-PRODUCTION.txt describes its packaged
HTTPS gateway, release deployment, production owner setup, and recovery.
- Building a release does not merge the feature branch or activate production.
+ Building a release does not activate production.
SQLite snapshots use the bundled SQLite C library. Release scripts build
static OpenSSL against hash-verified musl headers and the Rust musl target.
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt
index 2ee3f01..a08d850 100644
--- a/crates/chat-crypto/README.txt
+++ b/crates/chat-crypto/README.txt
@@ -32,7 +32,7 @@ PRIVATE CHAT CRYPTOGRAPHY
See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup,
tests, backups, and limitations. See ../../docs/CHAT-PRODUCTION.txt for
- packaged HTTPS integration. Work remains experimental on feature/private-chat;
+ packaged HTTPS integration. Work remains experimental;
compatibility tests are not an application security audit.
../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision,
diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs
index 98e8a20..963e1de 100644
--- a/crates/chat-crypto/src/application.rs
+++ b/crates/chat-crypto/src/application.rs
@@ -32,7 +32,8 @@ use std::io::{Read, Write};
use zeroize::Zeroizing;
const PURPOSE: &str = "Stack private chat identity only; do not reuse";
-const FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm";
+const LEGACY_FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm";
+const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm";
const LIMIT: usize = 262_144;
#[derive(Deserialize, JsonSerialize)]
@@ -50,10 +51,17 @@ struct Envelope {
format: String,
salt: String,
nonce: String,
- ciphertext: String,
+ ciphertext: FileCiphertext,
tag: String,
}
+#[derive(Deserialize, JsonSerialize)]
+#[serde(untagged)]
+enum FileCiphertext {
+ Compact(String),
+ Wrapped(Vec<String>),
+}
+
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
enum Request {
@@ -201,11 +209,17 @@ fn protect(identity: &Identity, password: &str) -> Result<String> {
&plaintext,
&mut tag,
)?;
- Ok(serde_json::to_string(&Envelope {
+ let encoded = BASE64_STANDARD.encode(ciphertext);
+ let chunks = encoded
+ .as_bytes()
+ .chunks(80)
+ .map(|chunk| std::str::from_utf8(chunk).map(str::to_owned))
+ .collect::<std::result::Result<Vec<_>, _>>()?;
+ Ok(serde_json::to_string_pretty(&Envelope {
format: FORMAT.into(),
salt: BASE64_STANDARD.encode(salt),
nonce: BASE64_STANDARD.encode(nonce),
- ciphertext: BASE64_STANDARD.encode(ciphertext),
+ ciphertext: FileCiphertext::Wrapped(chunks),
tag: BASE64_STANDARD.encode(tag),
})?)
}
@@ -213,10 +227,20 @@ fn protect(identity: &Identity, password: &str) -> Result<String> {
fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> {
ensure!(file.len() <= LIMIT, "Identity file exceeds its limit");
let envelope: Envelope = serde_json::from_str(file)?;
- ensure!(
- envelope.format == FORMAT,
- "Unsupported identity file format"
- );
+ let encoded = match (envelope.format.as_str(), &envelope.ciphertext) {
+ (LEGACY_FORMAT, FileCiphertext::Compact(value)) => value.clone(),
+ (FORMAT, FileCiphertext::Wrapped(chunks)) => {
+ ensure!(
+ !chunks.is_empty()
+ && chunks
+ .iter()
+ .all(|chunk| !chunk.is_empty() && chunk.len() <= 80),
+ "Invalid ciphertext wrapping"
+ );
+ chunks.concat()
+ }
+ _ => anyhow::bail!("Unsupported identity file format"),
+ };
let salt = decode(&envelope.salt)?;
let nonce = decode(&envelope.nonce)?;
let tag = decode(&envelope.tag)?;
@@ -224,13 +248,13 @@ fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)>
salt.len() == 16 && nonce.len() == 12 && tag.len() == 16,
"Malformed envelope"
);
- let ciphertext = decode(&envelope.ciphertext)?;
+ let ciphertext = decode(&encoded)?;
let key = derive(password, &salt)?;
let plaintext = Zeroizing::new(decrypt_aead(
Cipher::aes_256_gcm(),
key.as_ref(),
Some(&nonce),
- FORMAT.as_bytes(),
+ envelope.format.as_bytes(),
&ciphertext,
&tag,
)?);
@@ -643,7 +667,7 @@ mod tests {
format: FORMAT.into(),
salt: BASE64_STANDARD.encode([0; 15]),
nonce: BASE64_STANDARD.encode([0; 12]),
- ciphertext: String::new(),
+ ciphertext: FileCiphertext::Wrapped(Vec::new()),
tag: BASE64_STANDARD.encode([0; 16]),
})?;
ensure!(
@@ -657,6 +681,74 @@ mod tests {
Ok(())
}
+ fn check_file_formats(
+ file: &str,
+ identity: &Identity,
+ password: &str,
+ origin: &str,
+ ) -> Result<()> {
+ ensure!(
+ file.starts_with("{\n \"format\""),
+ "Envelope is not indented"
+ );
+ let envelope: Envelope = serde_json::from_str(file)?;
+ let FileCiphertext::Wrapped(chunks) = &envelope.ciphertext else {
+ anyhow::bail!("Ciphertext is not wrapped");
+ };
+ let (last, complete) = chunks.split_last().context("Missing ciphertext")?;
+ ensure!(
+ complete.iter().all(|chunk| chunk.len() == 80) && (1..=80).contains(&last.len()),
+ "Wrong ciphertext width"
+ );
+ let salt = random::<16>()?;
+ let nonce = random::<12>()?;
+ let key = derive(password, &salt)?;
+ let mut tag = [0; 16];
+ let plaintext = Zeroizing::new(serde_json::to_vec(identity)?);
+ let ciphertext = encrypt_aead(
+ Cipher::aes_256_gcm(),
+ key.as_ref(),
+ Some(&nonce),
+ LEGACY_FORMAT.as_bytes(),
+ &plaintext,
+ &mut tag,
+ )?;
+ let legacy = Envelope {
+ format: LEGACY_FORMAT.into(),
+ salt: BASE64_STANDARD.encode(salt),
+ nonce: BASE64_STANDARD.encode(nonce),
+ ciphertext: FileCiphertext::Compact(BASE64_STANDARD.encode(ciphertext)),
+ tag: BASE64_STANDARD.encode(tag),
+ };
+ for historical in [
+ serde_json::to_string(&legacy)?,
+ serde_json::to_string_pretty(&legacy)?,
+ ] {
+ let (restored, _certificate) = unlock(&historical, password, origin)?;
+ ensure!(
+ restored.secret.as_str() == identity.secret.as_str(),
+ "Legacy identity changed"
+ );
+ ensure!(
+ unlock(&historical, "wrong-passphrase", origin).is_err(),
+ "Legacy password protection failed"
+ );
+ }
+ let mut malformed = serde_json::to_value(&envelope)?;
+ *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(["A".repeat(81)]);
+ ensure!(
+ unlock(&malformed.to_string(), password, origin).is_err(),
+ "Overlong ciphertext chunk accepted"
+ );
+ *malformed.get_mut("format").context("Format")? = json!(LEGACY_FORMAT);
+ *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(chunks.concat());
+ ensure!(
+ unlock(&malformed.to_string(), password, origin).is_err(),
+ "Unauthenticated file-version downgrade accepted"
+ );
+ Ok(())
+ }
+
#[test]
fn protected_file_and_authenticated_messages() -> Result<()> {
let origin = "http://127.0.0.1:9080";
@@ -668,6 +760,7 @@ mod tests {
.as_str()
.context("File")?;
let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?;
+ check_file_formats(owner_file, &owner_identity, password, origin)?;
let owner_public = public(&owner_cert)?;
let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?;
let visitor_file = visitor
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs
index 7a359e7..b0a9041 100644
--- a/crates/chat-server/tests/browser.mjs
+++ b/crates/chat-server/tests/browser.mjs
@@ -163,6 +163,13 @@ async function create(page, name, owner = false) {
await page.click('#create-form button');
const download = await downloaded;
const bytes = await readFile(await download.path());
+ const envelope = JSON.parse(bytes.toString());
+ assert.ok(envelope.format.startsWith('stack-chat-file/v2;'));
+ assert.ok(bytes.toString().startsWith('{\n "format"'));
+ assert.ok(Array.isArray(envelope.ciphertext));
+ assert.ok(envelope.ciphertext.length > 1);
+ assert.ok(envelope.ciphertext.slice(0,-1).every(chunk => chunk.length === 80));
+ assert.ok(envelope.ciphertext.at(-1).length > 0 && envelope.ciphertext.at(-1).length <= 80);
await waitStatus(page,'Reopen your downloaded');
assert.equal(await page.locator('#chat').isVisible(), false);
assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications);
diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt
index 6a25776..28c576b 100644
--- a/docs/CHAT-APPLICATION.txt
+++ b/docs/CHAT-APPLICATION.txt
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
EXPERIMENTAL PRIVATE CHAT
- Branch: feature/private-chat. Direct development and a prebuilt Podman/HTTPS
+ Available on main. Direct development and a prebuilt Podman/HTTPS
deployment path are implemented. See CHAT-PRODUCTION.txt for the release,
production setup, and recovery procedure; feature work does not itself merge
main or activate the VPS.
@@ -57,7 +57,7 @@ WHAT EXISTS
LOCAL PREREQUISITES AND COMMANDS
- Start in the root of the stack repository on feature/private-chat. Native
+ Start in the root of the stack repository on main. Native
development needs Rust, libclang, OpenSSL 3.5 or newer development libraries,
a C compiler, pkg-config, make, Perl, curl, and tar. The checked configuration
uses OpenSSL 3.6.5. The WASI build script currently targets an x86_64 GNU/Linux
@@ -167,11 +167,24 @@ FILE AND MESSAGE CRYPTOGRAPHY
a fixed purpose-only user ID. Names are not placed in public certificate IDs.
The server rejects uploaded secret-key material and unsupported certificates.
- The JSON identity file is an authenticated encrypted envelope. Its fixed
- format identifier is authenticated as associated data:
+ New JSON identity files are indented and use an array of 80-character
+ Base64 ciphertext chunks; the final chunk may be shorter. JSON indentation,
+ quotes, and commas are additional to the ciphertext width. The reader joins
+ the chunks before Base64 decoding. No private fields become plaintext.
+ This is the application's own JSON envelope, not OpenPGP ASCII armor.
+ The new file-format identifier is authenticated as associated data:
+
+ stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm
+
+ Existing compact v1 files with one ciphertext string remain supported:
stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm
+ v1 uses its original identifier as associated data. Changing a version label
+ or converting its field shape does not convert a file: the authentication tag
+ binds the version. No existing file needs rewriting; old readers do not support
+ new v2 exports. Cryptographic algorithms and KDF parameters are unchanged.
+
Argon2id version 0x13 uses 65536 KiB, three passes, one lane, and a 32-byte
derived key. A fresh CSPRNG salt of 16 bytes and AES-256-GCM nonce of 12 bytes
are generated for each file; the authentication tag is 16 bytes. These KDF
diff --git a/docs/CHAT-PRODUCTION.txt b/docs/CHAT-PRODUCTION.txt
index 7e0e315..c1a2402 100644
--- a/docs/CHAT-PRODUCTION.txt
+++ b/docs/CHAT-PRODUCTION.txt
@@ -16,8 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
PRODUCTION CHAT INTEGRATION
- Implemented on feature/private-chat. Building and testing a release does not
- merge main or activate the VPS. This is experimental software without an
+ Implemented on main after merging the original feature/private-chat branch.
+ Building and testing a release does not activate the VPS. This is experimental software without an
independent cryptographic or application security audit. Read the limitations
in CHAT-APPLICATION.txt before using it for private conversations.