Skip to content

Commit e787d84

Browse files
committed
ogar-auth: the reusable auth arm (TOTP + password + legacy transition + forward re-export)
New workspace crate `ogar-auth` — one authentication SDK every OGAR consumer pulls, so the auth primitives never diverge into per-consumer hand-rolled copies (the dilution this crate prevents). Fully agnostic: names no consumer, carries no key table, no PII. Surface: - password — Argon2id PHC hash + verify (forward login credential). getrandom is the only entropy source (matches the encryption crate). - totp — RFC 6238 (HMAC-SHA1 / 30s / 6-digit), base32 secret, +/-1-step skew, uniform-work compare. RFC 6238 Appendix B vectors pinned. - legacy — agnostic 3DES-EDE2 / PBKDF1-MD5 / zero-IV-CBC / PKCS#7 transition verifier. The password TABLE is a caller-supplied &[&str] — the algorithm is a documented .NET convention, the table is a private consumer secret this public crate never carries. - re-export — the ndarray `encryption` forward suite (XChaCha20-Poly1305 AEAD, SHA-384, Ed25519, wasm-capable zero-knowledge envelope), REUSED not re-implemented (git dep, mirrors the lance-graph-contract precedent in ogar-class-view). Reserved seam `federation` documents the endgame invariant: a federated (OGIT[auth] / OIDC / Zitadel) login and a local login converge on the SAME identity envelope before any authorization decision — the IdP adapter lands later as an ogar-adapter-* sibling. Applied from the attached patch; trailer normalized. Gates re-run on apply. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_016b33swuXE23hKtqxsHu9p1
1 parent 6d93484 commit e787d84

6 files changed

Lines changed: 773 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ members = [
2121
"crates/ogar-fma-skeleton",
2222
"crates/ogar-adapter-python",
2323
"crates/ogar-adapter-csharp",
24+
"crates/ogar-auth",
2425
]
2526

2627
[workspace.package]

crates/ogar-auth/Cargo.toml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
[package]
2+
name = "ogar-auth"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
description = "OGAR auth arm: the reusable authentication SDK for OGAR consumers. Agnostic RFC 6238 TOTP, Argon2id PHC password hash/verify, a legacy 3DES-EDE2/PBKDF1-MD5 transition primitive (caller-supplied key table — carries no secrets), and a re-export of the ndarray `encryption` forward suite (XChaCha20-Poly1305 / SHA-384 / Ed25519 / zero-knowledge envelope). One surface every consumer pulls, so the auth primitives never diverge. Endgame seam: OGIT[auth] IAM federation (OIDC / Zitadel)."
8+
9+
[dependencies]
10+
# Forward suite — REUSED from the ndarray fork, never re-implemented here.
11+
# Argon2id KDF, XChaCha20-Poly1305 AEAD, Ed25519 signatures, SHA-384, and the
12+
# wasm-capable zero-knowledge envelope all live in `encryption`; ogar-auth
13+
# re-exports them (see lib.rs) so a consumer imports ONE crate. Git dep,
14+
# matching the ogar-class-view → lance-graph-contract precedent (ndarray's
15+
# default branch is `master`).
16+
encryption = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master" }
17+
18+
# Argon2id PHC hash+verify (the login-credential path — distinct from the
19+
# envelope KDF). Default features carry `password-hash` + `rand` (OsRng salt).
20+
argon2 = "0.5"
21+
22+
# RFC 6238 TOTP: HMAC-SHA1 over the time-step counter. SHA1 here is
23+
# interop-mandated by the authenticator-app ecosystem and MAC-keyed (HMAC),
24+
# never a password or integrity hash.
25+
hmac = "0.12"
26+
sha1 = "0.10"
27+
28+
# Legacy 3DES-EDE2 transition (PBKDF1/MD5 key derivation, zero-IV CBC, PKCS#7):
29+
# the agnostic .NET `TripleDESCryptoServiceProvider` + `PasswordDeriveBytes`
30+
# algorithm. The caller supplies the password table; none is baked here.
31+
des = "0.8"
32+
md-5 = "0.10"
33+
cipher = "0.4"
34+
base64 = "0.22"
35+
36+
# The single entropy chokepoint (TOTP secret minting). On wasm32 the `js`
37+
# feature routes to crypto.getRandomValues; mirrors the encryption crate.
38+
getrandom = "0.2"
39+
40+
[target.'cfg(target_arch = "wasm32")'.dependencies]
41+
getrandom = { version = "0.2", features = ["js"] }

crates/ogar-auth/src/legacy.rs

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
//! Legacy 3DES-EDE2 transition — verify-during-migration, not a forward path.
2+
//!
3+
//! Some legacy corpora (classic .NET line-of-business apps) store secrets that
4+
//! `TripleDESCryptoServiceProvider` produced with `PasswordDeriveBytes` +
5+
//! `CryptDeriveKey`. To migrate such a corpus onto the forward suite
6+
//! ([`crate::password`] / [`crate::kdf`]) you must first *read* the old value
7+
//! once — decrypt it, then immediately re-protect it forward and discard the
8+
//! legacy path. This module is that one-way door.
9+
//!
10+
//! ## The algorithm (agnostic — carries no secrets)
11+
//!
12+
//! The .NET construction all such corpora share:
13+
//!
14+
//! - **Key derivation** — `PasswordDeriveBytes(password, emptySalt)` then
15+
//! `CryptDeriveKey("RC2","MD5",128,IV)`: `state = MD5(password)`, folded
16+
//! `iterations-1` times, one final `MD5(state)`; the first 16 bytes are the
17+
//! RC2-128 key, which `des.Key = <16 bytes>` interprets as TripleDES-EDE2
18+
//! (K3 = K1). See [`derive_tdes_key_pbkdf1_md5`].
19+
//! - **Cipher** — 3DES-EDE2, CBC, zero IV (`des.IV = new byte[8]`), PKCS#7.
20+
//! - **Storage** — `prefix ‖ base64(ciphertext)`, one prefix char naming a
21+
//! password-table index (base62 order: `0-9`, `a-z`, `A-Z`).
22+
//!
23+
//! **This crate bakes NO password table.** The 62-key (or however-many)
24+
//! table belongs to the consumer that owns the legacy corpus and is passed as
25+
//! a `&[&str]` argument. That is the whole reason this primitive is public and
26+
//! agnostic while the table stays private: the *algorithm* is a documented
27+
//! .NET convention; the *table* is a live secret.
28+
29+
use base64::Engine;
30+
use base64::engine::general_purpose::STANDARD as BASE64;
31+
use cipher::{BlockDecryptMut, BlockEncryptMut, KeyInit};
32+
use md5::{Digest, Md5};
33+
34+
use crate::{AuthError, AuthResult};
35+
36+
/// .NET `PasswordDeriveBytes` default iteration count.
37+
pub const DEFAULT_PBKDF1_ITERATIONS: usize = 100;
38+
39+
const MD5_LEN: usize = 16;
40+
const TDES_BLOCK: usize = 8;
41+
42+
/// Map a single ciphertext prefix char to its password-table index using the
43+
/// base62 order .NET's helper emits: `'0'..='9'` → 0..9, `'a'..='z'` → 10..35,
44+
/// `'A'..='Z'` → 36..61. Returns `None` for any other byte.
45+
#[must_use]
46+
pub fn prefix_to_index(c: char) -> Option<usize> {
47+
match c {
48+
'0'..='9' => Some(c as usize - '0' as usize),
49+
'a'..='z' => Some(10 + (c as usize - 'a' as usize)),
50+
'A'..='Z' => Some(36 + (c as usize - 'A' as usize)),
51+
_ => None,
52+
}
53+
}
54+
55+
/// Inverse of [`prefix_to_index`] (0..=61 → the prefix char).
56+
#[must_use]
57+
pub fn index_to_prefix(idx: usize) -> Option<char> {
58+
match idx {
59+
0..=9 => char::from_u32('0' as u32 + idx as u32),
60+
10..=35 => char::from_u32('a' as u32 + (idx - 10) as u32),
61+
36..=61 => char::from_u32('A' as u32 + (idx - 36) as u32),
62+
_ => None,
63+
}
64+
}
65+
66+
/// `.NET PasswordDeriveBytes` over MD5 with empty salt and `iterations`
67+
/// rounds, then the `CryptDeriveKey("RC2","MD5",128,…)` step, expanded to the
68+
/// 24-byte TripleDES-EDE2 key (K1‖K2‖K1).
69+
#[must_use]
70+
pub fn derive_tdes_key_pbkdf1_md5(password: &[u8], iterations: usize) -> [u8; 24] {
71+
// Step 1 — initial hash of password (empty salt appended unchanged).
72+
let mut hasher = Md5::new();
73+
hasher.update(password);
74+
let mut state: [u8; MD5_LEN] = hasher.finalize().into();
75+
76+
// Steps 2..iterations-1 — fold through MD5.
77+
for _ in 1..iterations.saturating_sub(1) {
78+
let mut h = Md5::new();
79+
h.update(state);
80+
state = h.finalize().into();
81+
}
82+
83+
// Output stream — first (only needed) chunk: MD5(state) → 16-byte RC2 key.
84+
let mut h = Md5::new();
85+
h.update(state);
86+
let derived: [u8; MD5_LEN] = h.finalize().into();
87+
88+
let mut k = [0u8; 24];
89+
k[..16].copy_from_slice(&derived);
90+
k[16..].copy_from_slice(&derived[..8]); // K3 = K1 (two-key EDE)
91+
k
92+
}
93+
94+
/// Encrypt `plaintext` with a 24-byte EDE2 key: 3DES CBC, zero IV, PKCS#7.
95+
/// The inverse of [`decrypt_cbc_zero_iv`]; deterministic (zero IV), which is
96+
/// what makes round-trip self-consistency testable.
97+
#[must_use]
98+
pub fn encrypt_cbc_zero_iv(key: &[u8; 24], plaintext: &[u8]) -> Vec<u8> {
99+
let mut tdes = des::TdesEde3::new_from_slice(key).expect("TripleDES key length is 24");
100+
let mut buf = plaintext.to_vec();
101+
let pad = TDES_BLOCK - (buf.len() % TDES_BLOCK);
102+
buf.extend(std::iter::repeat_n(pad as u8, pad));
103+
104+
let mut prev_iv = [0u8; TDES_BLOCK];
105+
for block in buf.chunks_mut(TDES_BLOCK) {
106+
for (b, iv) in block.iter_mut().zip(prev_iv.iter()) {
107+
*b ^= *iv;
108+
}
109+
let mut arr = [0u8; TDES_BLOCK];
110+
arr.copy_from_slice(block);
111+
tdes.encrypt_block_mut((&mut arr).into());
112+
block.copy_from_slice(&arr);
113+
prev_iv.copy_from_slice(block);
114+
}
115+
buf
116+
}
117+
118+
/// Decrypt `ciphertext` (a multiple of the 8-byte block size) with a 24-byte
119+
/// EDE2 key: 3DES CBC, zero IV, PKCS#7 strip. Returns the raw plaintext bytes.
120+
///
121+
/// # Errors
122+
/// [`AuthError::Legacy`] on block misalignment or PKCS#7 padding failure.
123+
pub fn decrypt_cbc_zero_iv(key: &[u8; 24], ciphertext: &[u8]) -> AuthResult<Vec<u8>> {
124+
if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(TDES_BLOCK) {
125+
return Err(AuthError::Legacy(
126+
"ciphertext is not a whole number of 3DES blocks",
127+
));
128+
}
129+
let mut tdes = des::TdesEde3::new_from_slice(key).expect("TripleDES key length is 24");
130+
let mut plain = Vec::with_capacity(ciphertext.len());
131+
let mut prev_iv = [0u8; TDES_BLOCK];
132+
for chunk in ciphertext.chunks(TDES_BLOCK) {
133+
let mut block = [0u8; TDES_BLOCK];
134+
block.copy_from_slice(chunk);
135+
let mut decoded = block;
136+
tdes.decrypt_block_mut((&mut decoded).into());
137+
for (b, iv) in decoded.iter_mut().zip(prev_iv.iter()) {
138+
*b ^= *iv;
139+
}
140+
plain.extend_from_slice(&decoded);
141+
prev_iv = block;
142+
}
143+
144+
let pad = *plain
145+
.last()
146+
.ok_or(AuthError::Legacy("empty plaintext after decrypt"))? as usize;
147+
if pad == 0 || pad > TDES_BLOCK || pad > plain.len() {
148+
return Err(AuthError::Legacy("PKCS#7 pad byte out of range"));
149+
}
150+
if plain[plain.len() - pad..]
151+
.iter()
152+
.any(|&b| b as usize != pad)
153+
{
154+
return Err(AuthError::Legacy("PKCS#7 padding bytes are not uniform"));
155+
}
156+
plain.truncate(plain.len() - pad);
157+
Ok(plain)
158+
}
159+
160+
/// Encrypt `plaintext` under `table[index]` and emit the stored form
161+
/// (`prefix ‖ base64(ciphertext)`). The inverse of [`decrypt_prefixed`];
162+
/// exists for round-trip self-consistency and for generating vectors to diff
163+
/// against a private oracle. NOT a forward path.
164+
///
165+
/// # Errors
166+
/// [`AuthError::Legacy`] if `index` is outside the prefix range or the table.
167+
pub fn encrypt_prefixed(
168+
plaintext: &str,
169+
index: usize,
170+
table: &[&str],
171+
iterations: usize,
172+
) -> AuthResult<String> {
173+
let prefix = index_to_prefix(index).ok_or(AuthError::Legacy("index has no prefix char"))?;
174+
let password = table
175+
.get(index)
176+
.ok_or(AuthError::Legacy("index outside password table"))?;
177+
let key = derive_tdes_key_pbkdf1_md5(password.as_bytes(), iterations);
178+
let ct = encrypt_cbc_zero_iv(&key, plaintext.as_bytes());
179+
Ok(format!("{prefix}{}", BASE64.encode(&ct)))
180+
}
181+
182+
/// Decrypt a stored legacy value (`prefix ‖ base64(ciphertext)`) using the
183+
/// caller-supplied password `table`. Returns `Ok(None)` for empty input.
184+
///
185+
/// The `table` is the consumer's private secret; this crate never carries one.
186+
///
187+
/// # Errors
188+
/// [`AuthError::Legacy`] on an unknown/out-of-range prefix, a truncated value,
189+
/// bad base64, block misalignment, or padding failure. [`AuthError::Encoding`]
190+
/// if the decrypted bytes are not valid UTF-8.
191+
pub fn decrypt_prefixed(
192+
input: &str,
193+
table: &[&str],
194+
iterations: usize,
195+
) -> AuthResult<Option<String>> {
196+
if input.is_empty() {
197+
return Ok(None);
198+
}
199+
let mut chars = input.chars();
200+
let prefix = chars
201+
.next()
202+
.ok_or(AuthError::Legacy("empty legacy value"))?;
203+
let index = prefix_to_index(prefix).ok_or(AuthError::Legacy("unrecognised prefix char"))?;
204+
let password = table
205+
.get(index)
206+
.ok_or(AuthError::Legacy("prefix index outside password table"))?;
207+
let body: String = chars.collect();
208+
if body.is_empty() {
209+
return Err(AuthError::Legacy("legacy value has a prefix but no body"));
210+
}
211+
212+
let ciphertext = BASE64
213+
.decode(body.as_bytes())
214+
.map_err(|_| AuthError::Encoding("legacy body is not base64"))?;
215+
let key = derive_tdes_key_pbkdf1_md5(password.as_bytes(), iterations);
216+
let plain = decrypt_cbc_zero_iv(&key, &ciphertext)?;
217+
let s = String::from_utf8(plain)
218+
.map_err(|_| AuthError::Encoding("legacy plaintext is not UTF-8"))?;
219+
Ok(Some(s))
220+
}
221+
222+
#[cfg(test)]
223+
mod tests {
224+
use super::*;
225+
226+
// A SYNTHETIC table — invented passwords, never a real corpus's secret.
227+
// It exists only to prove the machinery is internally coherent; byte-parity
228+
// against a real .NET oracle is a private consumer test, not a public one.
229+
const SYNTHETIC_TABLE: [&str; 62] = [
230+
"pw00", "pw01", "pw02", "pw03", "pw04", "pw05", "pw06", "pw07", "pw08", "pw09", "pw10",
231+
"pw11", "pw12", "pw13", "pw14", "pw15", "pw16", "pw17", "pw18", "pw19", "pw20", "pw21",
232+
"pw22", "pw23", "pw24", "pw25", "pw26", "pw27", "pw28", "pw29", "pw30", "pw31", "pw32",
233+
"pw33", "pw34", "pw35", "pw36", "pw37", "pw38", "pw39", "pw40", "pw41", "pw42", "pw43",
234+
"pw44", "pw45", "pw46", "pw47", "pw48", "pw49", "pw50", "pw51", "pw52", "pw53", "pw54",
235+
"pw55", "pw56", "pw57", "pw58", "pw59", "pw60", "pw61",
236+
];
237+
238+
#[test]
239+
fn prefix_index_round_trips() {
240+
for (c, i) in [
241+
('0', 0),
242+
('9', 9),
243+
('a', 10),
244+
('z', 35),
245+
('A', 36),
246+
('Z', 61),
247+
] {
248+
assert_eq!(prefix_to_index(c), Some(i));
249+
assert_eq!(index_to_prefix(i), Some(c));
250+
}
251+
assert_eq!(prefix_to_index('!'), None);
252+
assert_eq!(index_to_prefix(62), None);
253+
}
254+
255+
#[test]
256+
fn encrypt_decrypt_round_trips_for_every_index() {
257+
// Proves the plumbing (PBKDF1/MD5 → 3DES-EDE2 → CBC zero-IV → PKCS#7 →
258+
// prefix) is internally coherent for a caller-supplied table. It does
259+
// NOT prove parity with any real .NET oracle — that is a private test.
260+
let samples = ["", "a", "hunter2", "café — münchen — 12345", "block-align8"];
261+
for idx in [0usize, 1, 9, 10, 35, 36, 61] {
262+
for pt in samples {
263+
let ct =
264+
encrypt_prefixed(pt, idx, &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS).unwrap();
265+
assert_eq!(prefix_to_index(ct.chars().next().unwrap()), Some(idx));
266+
let back =
267+
decrypt_prefixed(&ct, &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS).unwrap();
268+
assert_eq!(back.as_deref(), Some(pt), "round-trip idx={idx} pt={pt:?}");
269+
}
270+
}
271+
}
272+
273+
#[test]
274+
fn decrypt_empty_is_none() {
275+
assert!(matches!(
276+
decrypt_prefixed("", &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS),
277+
Ok(None)
278+
));
279+
}
280+
281+
#[test]
282+
fn decrypt_unknown_prefix_errors() {
283+
assert!(matches!(
284+
decrypt_prefixed("!body", &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS),
285+
Err(AuthError::Legacy(_))
286+
));
287+
}
288+
289+
#[test]
290+
fn decrypt_prefix_only_errors() {
291+
assert!(matches!(
292+
decrypt_prefixed("0", &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS),
293+
Err(AuthError::Legacy(_))
294+
));
295+
}
296+
297+
#[test]
298+
fn encrypt_out_of_range_index_errors() {
299+
assert!(matches!(
300+
encrypt_prefixed("x", 62, &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS),
301+
Err(AuthError::Legacy(_))
302+
));
303+
}
304+
}

0 commit comments

Comments
 (0)