From 671d128b47522fc43f0a69aa0c7d33c0e1137bf3 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 5 Mar 2026 20:31:33 +0100 Subject: [PATCH 1/8] Handle +

Hello from we!

This is a from-scratch web browser engine written in pure Rust.

Zero external crate dependencies. Every subsystem is implemented in Rust.

Features

-

HTML5 tokenizer, DOM tree, block layout, and software rendering.

+

HTML5 tokenizer, DOM tree, block layout, CSS cascade, and software rendering.

"#; @@ -33,7 +41,7 @@ thread_local! { static STATE: RefCell> = const { RefCell::new(None) }; } -/// Re-run the full pipeline: parse → layout → render → copy to bitmap. +/// Re-run the full pipeline: parse → extract CSS → resolve styles → layout → render → copy to bitmap. fn render_page(html: &str, font: &Font, bitmap: &mut BitmapContext) { let width = bitmap.width() as u32; let height = bitmap.height() as u32; @@ -41,7 +49,14 @@ fn render_page(html: &str, font: &Font, bitmap: &mut BitmapContext) { return; } + // Parse HTML into DOM. let doc = parse_html(html); + + // Extract CSS from + + +

Title

+

Paragraph

+ +"#; + + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + assert_eq!(sheets.len(), 1); + + let styled = resolve_styles(&doc, &sheets).unwrap(); + // styled root is + // Find body (first visible child) + let body = &styled.children[0]; + + // Find h1 and p + let h1 = &body.children[0]; + let p = &body.children[1]; + + assert_eq!(h1.style.color, Color::rgb(0, 0, 255)); // blue + assert_eq!(p.style.color, Color::rgb(255, 0, 0)); // red + assert_eq!(p.style.font_size, 24.0); +} + +#[test] +fn html_with_multiple_style_elements() { + let html = r#" + + + + + +

text

+"#; + + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + assert_eq!(sheets.len(), 2); + + let styled = resolve_styles(&doc, &sheets).unwrap(); + let body = &styled.children[0]; + let p = &body.children[0]; + + // Later stylesheet wins. + assert_eq!(p.style.color, Color::rgb(0, 128, 0)); +} + +#[test] +fn html_with_inline_style_attribute() { + let html = r#" + + + + + +

text

+ +"#; + + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + + let styled = resolve_styles(&doc, &sheets).unwrap(); + let body = &styled.children[0]; + let p = &body.children[0]; + + // Inline style overrides stylesheet. + assert_eq!(p.style.color, Color::rgb(0, 0, 255)); +} + +#[test] +fn html_no_style_uses_ua_defaults() { + let html = r#" + + +

Title

+

Text

+ +"#; + + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + assert!(sheets.is_empty()); + + let styled = resolve_styles(&doc, &sheets).unwrap(); + let body = &styled.children[0]; + + // Body should have 8px margin from UA stylesheet. + assert_eq!(body.style.margin_top, LengthOrAuto::Length(8.0)); + + // h1 should be block with bold and 2em font-size. + let h1 = &body.children[0]; + assert_eq!(h1.style.display, Display::Block); + assert_eq!(h1.style.font_weight, FontWeight(700.0)); + assert_eq!(h1.style.font_size, 32.0); // 2em * 16px +} + +#[test] +fn style_element_display_none_in_styled_tree() { + let html = r#" + + + + +

text

+"#; + + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + + // The and + + +

Important

+

Normal

+ +"#; + + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let body = &styled.children[0]; + + let highlight_p = &body.children[0]; + let normal_p = &body.children[1]; + + assert_eq!(highlight_p.style.color, Color::rgb(255, 0, 0)); + assert_eq!(highlight_p.style.background_color, Color::rgb(255, 255, 0)); + + // Normal p inherits default color (black). + assert_eq!(normal_p.style.color, Color::rgb(0, 0, 0)); +} -- 2.51.2 From 3218f6587629a4671f9b45d3a96200e8ef4a414f Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 5 Mar 2026 20:53:24 +0100 Subject: [PATCH 2/8] Integrate computed styles into layout and render pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded tag-based defaults in the layout crate with CSS computed styles from the style crate. The layout engine now accepts a StyledNode tree instead of walking the DOM directly. Layout crate changes: - layout() takes &StyledNode + &Document instead of &Document alone - Remove hardcoded display_type(), default_font_size(), default_margin() - Read display, margin, padding, border, font-size from ComputedStyle - Carry color, background-color, text-decoration, border styles/colors on LayoutBox for the renderer to use Render crate changes: - Use CSS color for text (instead of hardcoded black) - Use CSS background-color for box backgrounds (skip transparent) - Render borders with correct width, style, and color - Support text-decoration: underline - Replace render-local Color type with we_css::values::Color Browser pipeline: - Full: HTML → DOM → extract CSS → resolve styles → layout → render 6 new tests across layout (3) and render (3) crates verifying CSS properties flow through to layout positions and paint commands. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 3 + crates/browser/src/main.rs | 9 +- crates/layout/Cargo.toml | 3 + crates/layout/src/lib.rs | 403 +++++++++++++++++++++---------------- crates/render/Cargo.toml | 4 + crates/render/src/lib.rs | 324 +++++++++++++++++++---------- 6 files changed, 461 insertions(+), 285 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f76422..d54ece9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,7 @@ version = "0.1.0" dependencies = [ "we-css", "we-dom", + "we-html", "we-style", "we-text", ] @@ -84,9 +85,11 @@ version = "0.1.0" dependencies = [ "we-css", "we-dom", + "we-html", "we-image", "we-layout", "we-platform", + "we-style", "we-text", ] diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 018b972..a087c2e 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -54,10 +54,13 @@ fn render_page(html: &str, font: &Font, bitmap: &mut BitmapContext) { // Extract CSS from + + +

First

+

Second

+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let first = &body_box.children[0]; + let second = &body_box.children[1]; + + // p has 50px top margin from CSS. + assert_eq!(first.margin.top, 50.0); + assert_eq!(first.margin.bottom, 50.0); + + // Second p should be well below first. + assert!(second.rect.y > first.rect.y + 100.0); + } + + #[test] + fn inline_style_affects_layout() { + let html_str = r#" + + +
+

Content

+
+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + assert_eq!(div_box.padding.top, 20.0); + assert_eq!(div_box.padding.bottom, 20.0); + } + + #[test] + fn css_color_propagates_to_layout() { + let html_str = r#" + + +

Colored

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert_eq!(p_box.color, Color::rgb(255, 0, 0)); + assert_eq!(p_box.background_color, Color::rgb(0, 0, 255)); + } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 0355b32..3ae24be 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -12,5 +12,9 @@ we-platform = { path = "../platform" } we-layout = { path = "../layout" } we-dom = { path = "../dom" } we-css = { path = "../css" } +we-style = { path = "../style" } we-text = { path = "../text" } we-image = { path = "../image" } + +[dev-dependencies] +we-html = { path = "../html" } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d7af917..3e89149 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -3,33 +3,11 @@ //! Walks a layout tree, generates paint commands, and rasterizes them //! into a BGRA pixel buffer suitable for display via CoreGraphics. -use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; +use we_css::values::Color; +use we_layout::{LayoutBox, LayoutTree, TextLine}; +use we_style::computed::{BorderStyle, TextDecoration}; use we_text::font::Font; -/// An RGBA color with 8-bit components. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Color { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -impl Color { - pub const BLACK: Color = Color { - r: 0, - g: 0, - b: 0, - a: 255, - }; - pub const WHITE: Color = Color { - r: 255, - g: 255, - b: 255, - a: 255, - }; -} - /// A paint command in the display list. #[derive(Debug)] pub enum PaintCommand { @@ -55,7 +33,7 @@ pub type DisplayList = Vec; /// Build a display list from a layout tree. /// /// Walks the tree in depth-first pre-order (painter's order): -/// backgrounds first, then text on top. +/// backgrounds first, then borders, then text on top. pub fn build_display_list(tree: &LayoutTree) -> DisplayList { let mut list = DisplayList::new(); paint_box(&tree.root, &mut list); @@ -63,10 +41,8 @@ pub fn build_display_list(tree: &LayoutTree) -> DisplayList { } fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { - // Paint background for block-level boxes. paint_background(layout_box, list); - - // Paint text lines (inline content). + paint_borders(layout_box, list); paint_text(layout_box, list); // Recurse into children. @@ -76,31 +52,100 @@ fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { } fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { - match &layout_box.box_type { - BoxType::Block(_) | BoxType::Anonymous => { - // Paint a white background for block boxes. - // Only emit background if the box has non-zero area. - if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { - list.push(PaintCommand::FillRect { - x: layout_box.rect.x, - y: layout_box.rect.y, - width: layout_box.rect.width, - height: layout_box.rect.height, - color: Color::WHITE, - }); - } - } - BoxType::Inline(_) | BoxType::TextRun { .. } => {} + let bg = layout_box.background_color; + // Only paint if the background is not fully transparent and the box has area. + if bg.a == 0 { + return; + } + if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { + // Background covers the padding box (content + padding), not including border. + list.push(PaintCommand::FillRect { + x: layout_box.rect.x, + y: layout_box.rect.y, + width: layout_box.rect.width, + height: layout_box.rect.height, + color: bg, + }); + } +} + +fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { + let b = &layout_box.border; + let r = &layout_box.rect; + let styles = &layout_box.border_styles; + let colors = &layout_box.border_colors; + + // Border box starts at content origin minus padding and border. + let bx = r.x - layout_box.padding.left - b.left; + let by = r.y - layout_box.padding.top - b.top; + let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; + let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; + + // Top border + if b.top > 0.0 && styles[0] != BorderStyle::None && styles[0] != BorderStyle::Hidden { + list.push(PaintCommand::FillRect { + x: bx, + y: by, + width: bw, + height: b.top, + color: colors[0], + }); + } + // Right border + if b.right > 0.0 && styles[1] != BorderStyle::None && styles[1] != BorderStyle::Hidden { + list.push(PaintCommand::FillRect { + x: bx + bw - b.right, + y: by, + width: b.right, + height: bh, + color: colors[1], + }); + } + // Bottom border + if b.bottom > 0.0 && styles[2] != BorderStyle::None && styles[2] != BorderStyle::Hidden { + list.push(PaintCommand::FillRect { + x: bx, + y: by + bh - b.bottom, + width: bw, + height: b.bottom, + color: colors[2], + }); + } + // Left border + if b.left > 0.0 && styles[3] != BorderStyle::None && styles[3] != BorderStyle::Hidden { + list.push(PaintCommand::FillRect { + x: bx, + y: by, + width: b.left, + height: bh, + color: colors[3], + }); } } fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { + let color = layout_box.color; + let underline = layout_box.text_decoration == TextDecoration::Underline; + for line in &layout_box.lines { list.push(PaintCommand::DrawGlyphs { line: line.clone(), font_size: layout_box.font_size, - color: Color::BLACK, + color, }); + + // Draw underline as a 1px line below the baseline. + if underline && line.width > 0.0 { + let baseline_y = line.y + layout_box.font_size; + let underline_y = baseline_y + 2.0; // 2px below baseline + list.push(PaintCommand::FillRect { + x: line.x, + y: underline_y, + width: line.width, + height: 1.0, + color, + }); + } } } @@ -173,15 +218,37 @@ impl Renderer { } /// Fill a rectangle with a solid color. - fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { + pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { let x0 = (x as i32).max(0) as u32; let y0 = (y as i32).max(0) as u32; let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; - for py in y0..y1 { - for px in x0..x1 { - self.set_pixel(px, py, color); + if color.a == 255 { + // Fully opaque — direct write. + for py in y0..y1 { + for px in x0..x1 { + self.set_pixel(px, py, color); + } + } + } else if color.a > 0 { + // Semi-transparent — alpha blend. + let alpha = color.a as u32; + let inv_alpha = 255 - alpha; + for py in y0..y1 { + for px in x0..x1 { + let offset = ((py * self.width + px) * 4) as usize; + let dst_b = self.buffer[offset] as u32; + let dst_g = self.buffer[offset + 1] as u32; + let dst_r = self.buffer[offset + 2] as u32; + self.buffer[offset] = + ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; + self.buffer[offset + 1] = + ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; + self.buffer[offset + 2] = + ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; + self.buffer[offset + 3] = 255; + } } } } @@ -272,6 +339,7 @@ impl Renderer { mod tests { use super::*; use we_dom::Document; + use we_style::computed::{extract_stylesheets, resolve_styles}; use we_text::font::Font; fn test_font() -> Font { @@ -288,6 +356,13 @@ mod tests { panic!("no test font found"); } + fn layout_doc(doc: &Document) -> we_layout::LayoutTree { + let font = test_font(); + let sheets = extract_stylesheets(doc); + let styled = resolve_styles(doc, &sheets).unwrap(); + we_layout::layout(&styled, doc, 800.0, 600.0, &font) + } + #[test] fn renderer_new_white_background() { let r = Renderer::new(10, 10); @@ -303,12 +378,7 @@ mod tests { #[test] fn fill_rect_basic() { let mut r = Renderer::new(20, 20); - let red = Color { - r: 255, - g: 0, - b: 0, - a: 255, - }; + let red = Color::new(255, 0, 0, 255); r.fill_rect(5.0, 5.0, 10.0, 10.0, red); // Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255). @@ -328,12 +398,7 @@ mod tests { #[test] fn fill_rect_clipping() { let mut r = Renderer::new(10, 10); - let blue = Color { - r: 0, - g: 0, - b: 255, - a: 255, - }; + let blue = Color::new(0, 0, 255, 255); // Rect extends beyond the buffer — should not panic. r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue); @@ -343,42 +408,21 @@ mod tests { } } - #[test] - fn color_constants() { - assert_eq!( - Color::BLACK, - Color { - r: 0, - g: 0, - b: 0, - a: 255 - } - ); - assert_eq!( - Color::WHITE, - Color { - r: 255, - g: 255, - b: 255, - a: 255 - } - ); - } - #[test] fn display_list_from_empty_layout() { - let font = test_font(); let doc = Document::new(); - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); - let list = build_display_list(&tree); - // Empty document should produce no paint commands (or just a background). - // Just check it doesn't panic. - assert!(list.len() <= 1); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets); + if let Some(styled) = styled { + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); + let list = build_display_list(&tree); + assert!(list.len() <= 1); + } } #[test] fn display_list_has_background_and_text() { - let font = test_font(); let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -390,23 +434,18 @@ mod tests { doc.append_child(body, p); doc.append_child(p, text); - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let tree = layout_doc(&doc); let list = build_display_list(&tree); - let has_fill = list - .iter() - .any(|c| matches!(c, PaintCommand::FillRect { .. })); let has_text = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); - assert!(has_fill, "should have at least one FillRect"); assert!(has_text, "should have at least one DrawGlyphs"); } #[test] fn paint_simple_page() { - let font = test_font(); let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -418,7 +457,8 @@ mod tests { doc.append_child(body, p); doc.append_child(p, text); - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let font = test_font(); + let tree = layout_doc(&doc); let mut renderer = Renderer::new(800, 600); renderer.paint(&tree, &font); @@ -435,12 +475,7 @@ mod tests { #[test] fn bgra_format_correct() { let mut r = Renderer::new(1, 1); - let color = Color { - r: 100, - g: 150, - b: 200, - a: 255, - }; + let color = Color::new(100, 150, 200, 255); r.set_pixel(0, 0, color); let pixels = r.pixels(); // BGRA format. @@ -452,7 +487,6 @@ mod tests { #[test] fn paint_heading_produces_larger_glyphs() { - let font = test_font(); let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -468,7 +502,7 @@ mod tests { doc.append_child(body, p); doc.append_child(p, p_text); - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let tree = layout_doc(&doc); let list = build_display_list(&tree); // There should be DrawGlyphs commands with different font sizes. @@ -501,7 +535,6 @@ mod tests { #[test] fn glyph_compositing_anti_aliased() { // Render text and verify we get anti-aliased (partially transparent) pixels. - let font = test_font(); let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -513,7 +546,8 @@ mod tests { doc.append_child(body, p); doc.append_child(p, text); - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let font = test_font(); + let tree = layout_doc(&doc); let mut renderer = Renderer::new(800, 600); renderer.paint(&tree, &font); @@ -536,4 +570,80 @@ mod tests { "should have anti-aliased (gray) pixels from glyph compositing" ); } + + #[test] + fn css_color_renders_correctly() { + let html_str = r#" + + +

Red text

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); + + let list = build_display_list(&tree); + let text_colors: Vec<&Color> = list + .iter() + .filter_map(|c| match c { + PaintCommand::DrawGlyphs { color, .. } => Some(color), + _ => None, + }) + .collect(); + + assert!(!text_colors.is_empty()); + // Text should be red. + assert_eq!(*text_colors[0], Color::rgb(255, 0, 0)); + } + + #[test] + fn css_background_color_renders() { + let html_str = r#" + + +
Content
+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); + + let list = build_display_list(&tree); + let fill_colors: Vec<&Color> = list + .iter() + .filter_map(|c| match c { + PaintCommand::FillRect { color, .. } => Some(color), + _ => None, + }) + .collect(); + + // Should have a yellow fill rect for the div background. + assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0))); + } + + #[test] + fn border_rendering() { + let html_str = r#" + + +
Bordered
+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); + + let list = build_display_list(&tree); + let red_fills: Vec<_> = list + .iter() + .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0))) + .collect(); + + // Should have 4 border fills (top, right, bottom, left). + assert_eq!(red_fills.len(), 4, "should have 4 border edges"); + } } -- 2.51.2 From fa1afc9e01ad350bb63c7bcf50f662dac4432f7c Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 5 Mar 2026 21:23:04 +0100 Subject: [PATCH 3/8] Implement inline formatting context with per-fragment styling Replace the simplified inline text layout with a proper inline formatting context (IFC): Layout crate: - Flatten inline tree into items (words, spaces, breaks, inline starts/ends) - Word-wrap into line boxes respecting per-fragment font sizes - Handle
as forced line breaks - Apply text-align (left, center, right) via horizontal offset - Use computed line-height instead of hardcoded font_size * 1.2 - Inline box model: margin/padding/border offsets for inline elements - TextLine now carries per-fragment styling (font_size, color, text_decoration, background_color) instead of inheriting from parent - Store text_align and line_height on LayoutBox Render crate: - paint_text uses per-TextLine styling (color, font_size, text_decoration) - Render inline fragment backgrounds when not transparent - Underline uses per-fragment font_size for baseline calculation Tests: 7 new (per-fragment styling,
, text-align center/right, inline padding, font_size per fragment, line-height) Co-Authored-By: Claude Opus 4.6 --- crates/layout/src/lib.rs | 660 +++++++++++++++++++++++++++++++-------- crates/render/src/lib.rs | 35 ++- 2 files changed, 552 insertions(+), 143 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 65b171b..5e4d3f0 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1,12 +1,12 @@ //! Block layout engine: box generation, block/inline layout, and text wrapping. //! //! Builds a layout tree from a styled tree (DOM + computed styles) and positions -//! block-level elements vertically with text wrapping. +//! block-level elements vertically with proper inline formatting context. use we_css::values::Color; use we_dom::{Document, NodeData, NodeId}; use we_style::computed::{ - BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextDecoration, + BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextAlign, TextDecoration, }; use we_text::font::Font; @@ -41,13 +41,21 @@ pub enum BoxType { Anonymous, } -/// A single line of wrapped text. +/// A single positioned text fragment with its own styling. +/// +/// Multiple fragments can share the same y-coordinate when they are +/// on the same visual line (e.g. `

Hello world

` produces +/// two fragments at the same y). #[derive(Debug, Clone, PartialEq)] pub struct TextLine { pub text: String, pub x: f32, pub y: f32, pub width: f32, + pub font_size: f32, + pub color: Color, + pub text_decoration: TextDecoration, + pub background_color: Color, } /// A box in the layout tree with dimensions and child boxes. @@ -60,7 +68,7 @@ pub struct LayoutBox { pub border: EdgeSizes, pub children: Vec, pub font_size: f32, - /// Wrapped text lines (populated for boxes with inline content). + /// Positioned text fragments (populated for boxes with inline content). pub lines: Vec, /// Text color. pub color: Color, @@ -72,6 +80,10 @@ pub struct LayoutBox { pub border_styles: [BorderStyle; 4], /// Border colors (top, right, bottom, left). pub border_colors: [Color; 4], + /// Text alignment for this box's inline content. + pub text_align: TextAlign, + /// Computed line height in px. + pub line_height: f32, } impl LayoutBox { @@ -100,6 +112,8 @@ impl LayoutBox { style.border_bottom_color, style.border_left_color, ], + text_align: style.text_align, + line_height: style.line_height, } } @@ -130,7 +144,6 @@ impl<'a> Iterator for LayoutBoxIter<'a> { fn next(&mut self) -> Option<&'a LayoutBox> { let node = self.stack.pop()?; - // Push children in reverse so leftmost child is visited first. for child in node.children.iter().rev() { self.stack.push(child); } @@ -174,8 +187,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { match doc.node_data(node) { NodeData::Document => { - // Shouldn't reach here since resolve_styles produces element root, - // but handle gracefully. let mut children = Vec::new(); for child in &styled.children { if let Some(child_box) = build_box(child, doc) { @@ -193,7 +204,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { } } NodeData::Element { .. } => { - // display:none is already filtered by resolve_styles, but guard anyway. if style.display == Display::None { return None; } @@ -210,7 +220,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { bottom: style.padding_bottom, left: style.padding_left, }; - // Only apply border widths when border-style is not none. let border = EdgeSizes { top: if style.border_top_style != BorderStyle::None { style.border_top_width @@ -247,7 +256,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { Display::None => unreachable!(), }; - // For block containers, ensure children are uniformly block or inline. if style.display == Display::Block { children = normalize_children(children, style); } @@ -276,7 +284,7 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { } } -/// Collapse runs of whitespace to a single space. Preserves non-whitespace content. +/// Collapse runs of whitespace to a single space. fn collapse_whitespace(s: &str) -> String { let mut result = String::new(); let mut in_ws = false; @@ -303,17 +311,14 @@ fn normalize_children(children: Vec, parent_style: &ComputedStyle) -> let has_block = children.iter().any(is_block_level); if !has_block { - // All inline — parent will do inline layout directly. return children; } let has_inline = children.iter().any(|c| !is_block_level(c)); if !has_inline { - // All block — no wrapping needed. return children; } - // Mixed: wrap consecutive inline runs in anonymous blocks. let mut result = Vec::new(); let mut inline_group: Vec = Vec::new(); @@ -348,9 +353,14 @@ fn is_block_level(b: &LayoutBox) -> bool { // --------------------------------------------------------------------------- /// Position and size a layout box within `available_width` at position (`x`, `y`). -/// -/// `x` and `y` mark the top-left corner of the box's margin area. -fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font: &Font) { +fn compute_layout( + b: &mut LayoutBox, + x: f32, + y: f32, + available_width: f32, + font: &Font, + doc: &Document, +) { let content_x = x + b.margin.left + b.border.left + b.padding.left; let content_y = y + b.margin.top + b.border.top + b.padding.top; let content_width = (available_width @@ -369,9 +379,9 @@ fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font: match &b.box_type { BoxType::Block(_) | BoxType::Anonymous => { if has_block_children(b) { - layout_block_children(b, font); + layout_block_children(b, font, doc); } else { - layout_inline_children(b, font); + layout_inline_children(b, font, doc); } } BoxType::TextRun { .. } | BoxType::Inline(_) => { @@ -385,126 +395,308 @@ fn has_block_children(b: &LayoutBox) -> bool { } /// Lay out block-level children: stack them vertically. -fn layout_block_children(parent: &mut LayoutBox, font: &Font) { +fn layout_block_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { let content_x = parent.rect.x; let content_width = parent.rect.width; let mut cursor_y = parent.rect.y; for child in &mut parent.children { - compute_layout(child, content_x, cursor_y, content_width, font); + compute_layout(child, content_x, cursor_y, content_width, font, doc); cursor_y += child.margin_box_height(); } parent.rect.height = cursor_y - parent.rect.y; } -/// Lay out inline children: collect text, word-wrap, and compute height. -fn layout_inline_children(parent: &mut LayoutBox, font: &Font) { - let text = collect_inline_text(&parent.children); - if text.is_empty() { - parent.rect.height = 0.0; - return; - } - - let line_height = parent.font_size * 1.2; - let wrapped = wrap_text(&text, parent.rect.width, font, parent.font_size); - - let mut y = parent.rect.y; - let mut positioned = Vec::with_capacity(wrapped.len()); - for line in wrapped { - positioned.push(TextLine { - text: line.text, - x: parent.rect.x, - y, - width: line.width, - }); - y += line_height; - } +// --------------------------------------------------------------------------- +// Inline formatting context +// --------------------------------------------------------------------------- - parent.rect.height = positioned.len() as f32 * line_height; - parent.lines = positioned; +/// An inline item produced by flattening the inline tree. +enum InlineItemKind { + /// A word of text with associated styling. + Word { + text: String, + font_size: f32, + color: Color, + text_decoration: TextDecoration, + background_color: Color, + }, + /// Whitespace between words. + Space { font_size: f32 }, + /// Forced line break (`
`). + ForcedBreak, + /// Start of an inline box (for margin/padding/border tracking). + InlineStart { + margin_left: f32, + padding_left: f32, + border_left: f32, + }, + /// End of an inline box. + InlineEnd { + margin_right: f32, + padding_right: f32, + border_right: f32, + }, } -/// Recursively collect all text from inline children. -fn collect_inline_text(children: &[LayoutBox]) -> String { - let mut result = String::new(); - collect_text_recursive(children, &mut result); - result +/// A pending fragment on the current line. +struct PendingFragment { + text: String, + x: f32, + width: f32, + font_size: f32, + color: Color, + text_decoration: TextDecoration, + background_color: Color, } -fn collect_text_recursive(children: &[LayoutBox], result: &mut String) { +/// Flatten the inline children tree into a sequence of items. +fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec) { for child in children { match &child.box_type { BoxType::TextRun { text, .. } => { - result.push_str(text); + let words = split_into_words(text); + for segment in words { + match segment { + WordSegment::Word(w) => { + items.push(InlineItemKind::Word { + text: w, + font_size: child.font_size, + color: child.color, + text_decoration: child.text_decoration, + background_color: child.background_color, + }); + } + WordSegment::Space => { + items.push(InlineItemKind::Space { + font_size: child.font_size, + }); + } + } + } } - BoxType::Inline(_) => { - collect_text_recursive(&child.children, result); + BoxType::Inline(node_id) => { + if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) { + if tag_name == "br" { + items.push(InlineItemKind::ForcedBreak); + continue; + } + } + + items.push(InlineItemKind::InlineStart { + margin_left: child.margin.left, + padding_left: child.padding.left, + border_left: child.border.left, + }); + + flatten_inline_tree(&child.children, doc, items); + + items.push(InlineItemKind::InlineEnd { + margin_right: child.margin.right, + padding_right: child.padding.right, + border_right: child.border.right, + }); } _ => {} } } } -// --------------------------------------------------------------------------- -// Text measurement and word wrapping -// --------------------------------------------------------------------------- +enum WordSegment { + Word(String), + Space, +} -/// Measure the total advance width of a text string at the given font size. -fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { - let shaped = font.shape_text(text, font_size); - match shaped.last() { - Some(last) => last.x_offset + last.x_advance, - None => 0.0, +/// Split text into alternating words and spaces. +fn split_into_words(text: &str) -> Vec { + let mut segments = Vec::new(); + let mut current_word = String::new(); + + for ch in text.chars() { + if ch == ' ' { + if !current_word.is_empty() { + segments.push(WordSegment::Word(std::mem::take(&mut current_word))); + } + segments.push(WordSegment::Space); + } else { + current_word.push(ch); + } } + + if !current_word.is_empty() { + segments.push(WordSegment::Word(current_word)); + } + + segments } -/// Word-wrap text to fit within `max_width`. -fn wrap_text(text: &str, max_width: f32, font: &Font, font_size: f32) -> Vec { - let words: Vec<&str> = text.split_whitespace().collect(); - if words.is_empty() { - return Vec::new(); +/// Lay out inline children using a proper inline formatting context. +fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { + let available_width = parent.rect.width; + let text_align = parent.text_align; + let line_height = parent.line_height; + + let mut items = Vec::new(); + flatten_inline_tree(&parent.children, doc, &mut items); + + if items.is_empty() { + parent.rect.height = 0.0; + return; } - let space_width = measure_text_width(font, " ", font_size); - let mut lines = Vec::new(); - let mut line_text = String::new(); - let mut line_width: f32 = 0.0; - - for word in &words { - let word_width = measure_text_width(font, word, font_size); - - if line_text.is_empty() { - // First word on line — always accept. - line_text.push_str(word); - line_width = word_width; - } else if line_width + space_width + word_width <= max_width { - line_text.push(' '); - line_text.push_str(word); - line_width += space_width + word_width; - } else { - // Emit current line, start new one. - lines.push(TextLine { - text: line_text, - x: 0.0, - y: 0.0, - width: line_width, + // Process items into line boxes. + let mut all_lines: Vec> = Vec::new(); + let mut current_line: Vec = Vec::new(); + let mut cursor_x: f32 = 0.0; + + for item in &items { + match item { + InlineItemKind::Word { + text, + font_size, + color, + text_decoration, + background_color, + } => { + let word_width = measure_text_width(font, text, *font_size); + + // If this word doesn't fit and the line isn't empty, break. + if cursor_x > 0.0 && cursor_x + word_width > available_width { + all_lines.push(std::mem::take(&mut current_line)); + cursor_x = 0.0; + } + + current_line.push(PendingFragment { + text: text.clone(), + x: cursor_x, + width: word_width, + font_size: *font_size, + color: *color, + text_decoration: *text_decoration, + background_color: *background_color, + }); + cursor_x += word_width; + } + InlineItemKind::Space { font_size } => { + // Only add space if we have content on the line. + if !current_line.is_empty() { + let space_width = measure_text_width(font, " ", *font_size); + if cursor_x + space_width <= available_width { + cursor_x += space_width; + } + } + } + InlineItemKind::ForcedBreak => { + all_lines.push(std::mem::take(&mut current_line)); + cursor_x = 0.0; + } + InlineItemKind::InlineStart { + margin_left, + padding_left, + border_left, + } => { + cursor_x += margin_left + padding_left + border_left; + } + InlineItemKind::InlineEnd { + margin_right, + padding_right, + border_right, + } => { + cursor_x += margin_right + padding_right + border_right; + } + } + } + + // Flush the last line. + if !current_line.is_empty() { + all_lines.push(current_line); + } + + if all_lines.is_empty() { + parent.rect.height = 0.0; + return; + } + + // Position lines vertically and apply text-align. + let mut text_lines = Vec::new(); + let mut y = parent.rect.y; + let num_lines = all_lines.len(); + + for (line_idx, line_fragments) in all_lines.iter().enumerate() { + if line_fragments.is_empty() { + y += line_height; + continue; + } + + // Compute line width from last fragment. + let line_width = match line_fragments.last() { + Some(last) => last.x + last.width, + None => 0.0, + }; + + // Compute text-align offset. + let is_last_line = line_idx == num_lines - 1; + let align_offset = + compute_align_offset(text_align, available_width, line_width, is_last_line); + + for frag in line_fragments { + text_lines.push(TextLine { + text: frag.text.clone(), + x: parent.rect.x + frag.x + align_offset, + y, + width: frag.width, + font_size: frag.font_size, + color: frag.color, + text_decoration: frag.text_decoration, + background_color: frag.background_color, }); - line_text = word.to_string(); - line_width = word_width; } + + y += line_height; } - if !line_text.is_empty() { - lines.push(TextLine { - text: line_text, - x: 0.0, - y: 0.0, - width: line_width, - }); + parent.rect.height = num_lines as f32 * line_height; + parent.lines = text_lines; +} + +/// Compute the horizontal offset for text alignment. +fn compute_align_offset( + align: TextAlign, + available_width: f32, + line_width: f32, + is_last_line: bool, +) -> f32 { + let extra_space = (available_width - line_width).max(0.0); + match align { + TextAlign::Left => 0.0, + TextAlign::Center => extra_space / 2.0, + TextAlign::Right => extra_space, + TextAlign::Justify => { + // Don't justify the last line (CSS spec behavior). + if is_last_line { + 0.0 + } else { + // For justify, we shift the whole line by 0 — the actual distribution + // of space between words would need per-word spacing. For now, treat + // as left-aligned; full justify support is a future enhancement. + 0.0 + } + } } +} + +// --------------------------------------------------------------------------- +// Text measurement +// --------------------------------------------------------------------------- - lines +/// Measure the total advance width of a text string at the given font size. +fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { + let shaped = font.shape_text(text, font_size); + match shaped.last() { + Some(last) => last.x_offset + last.x_advance, + None => 0.0, + } } // --------------------------------------------------------------------------- @@ -532,7 +724,7 @@ pub fn layout( } }; - compute_layout(&mut root, 0.0, 0.0, viewport_width, font); + compute_layout(&mut root, 0.0, 0.0, viewport_width, font, doc); let height = root.margin_box_height(); LayoutTree { @@ -548,7 +740,6 @@ mod tests { use we_dom::Document; use we_style::computed::{extract_stylesheets, resolve_styles}; - // Helper: load a system font for testing. fn test_font() -> Font { let paths = [ "/System/Library/Fonts/Geneva.ttf", @@ -563,7 +754,6 @@ mod tests { panic!("no test font found"); } - // Helper: build a DOM, resolve styles, and lay it out. fn layout_doc(doc: &Document) -> LayoutTree { let font = test_font(); let sheets = extract_stylesheets(doc); @@ -581,7 +771,6 @@ mod tests { let tree = layout(&styled, &doc, 800.0, 600.0, &font); assert_eq!(tree.width, 800.0); } - // Empty document with no styled root is fine — just produces empty layout. } #[test] @@ -599,21 +788,30 @@ mod tests { let tree = layout_doc(&doc); - // Root should be the html element box. assert!(matches!(tree.root.box_type, BoxType::Block(_))); - // Find the p box (html > body > p). let body_box = &tree.root.children[0]; assert!(matches!(body_box.box_type, BoxType::Block(_))); let p_box = &body_box.children[0]; assert!(matches!(p_box.box_type, BoxType::Block(_))); - // p should have text lines. - assert!(!p_box.lines.is_empty(), "p should have wrapped text lines"); - assert_eq!(p_box.lines[0].text, "Hello world"); + assert!(!p_box.lines.is_empty(), "p should have text fragments"); + + // Collect all text on the first visual line. + let first_y = p_box.lines[0].y; + let line_text: String = p_box + .lines + .iter() + .filter(|l| (l.y - first_y).abs() < 0.01) + .map(|l| l.text.as_str()) + .collect::>() + .join(" "); + assert!( + line_text.contains("Hello") && line_text.contains("world"), + "line should contain Hello and world, got: {line_text}" + ); - // p should have vertical margins (1em = 16px default from UA stylesheet). assert_eq!(p_box.margin.top, 16.0); assert_eq!(p_box.margin.bottom, 16.0); } @@ -640,7 +838,6 @@ mod tests { let first = &body_box.children[0]; let second = &body_box.children[1]; - // Second paragraph should be below the first. assert!( second.rect.y > first.rect.y, "second p (y={}) should be below first p (y={})", @@ -671,7 +868,6 @@ mod tests { let h1_box = &body_box.children[0]; let p_box = &body_box.children[1]; - // h1 should have a larger font size (2em = 32px). assert!( h1_box.font_size > p_box.font_size, "h1 font_size ({}) should be > p font_size ({})", @@ -680,7 +876,6 @@ mod tests { ); assert_eq!(h1_box.font_size, 32.0); - // h1 should take more vertical space. assert!( h1_box.rect.height > p_box.rect.height, "h1 height ({}) should be > p height ({})", @@ -710,7 +905,6 @@ mod tests { assert_eq!(body_box.margin.bottom, 8.0); assert_eq!(body_box.margin.left, 8.0); - // Body content should be offset by 8px from html content edge. assert_eq!(body_box.rect.x, 8.0); assert_eq!(body_box.rect.y, 8.0); } @@ -732,16 +926,19 @@ mod tests { let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets).unwrap(); - // Narrow viewport: 100px (minus body margin 8+8 = 84px content width). let tree = layout(&styled, &doc, 100.0, 600.0, &font); let body_box = &tree.root.children[0]; let p_box = &body_box.children[0]; - // With a 84px content width, long text should wrap to multiple lines. + // Count distinct y-positions to count visual lines. + let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); + assert!( - p_box.lines.len() > 1, - "text should wrap to multiple lines, got {} lines", - p_box.lines.len() + ys.len() > 1, + "text should wrap to multiple lines, got {} visual lines", + ys.len() ); } @@ -760,13 +957,11 @@ mod tests { let tree = layout_doc(&doc); - // All boxes should have non-negative dimensions. for b in tree.iter() { assert!(b.rect.width >= 0.0, "width should be >= 0"); assert!(b.rect.height >= 0.0, "height should be >= 0"); } - // Overall layout should have positive height. assert!(tree.height > 0.0, "layout height should be > 0"); } @@ -791,7 +986,6 @@ mod tests { let tree = layout_doc(&doc); - // html should have one child (body), head is display:none. assert_eq!( tree.root.children.len(), 1, @@ -801,7 +995,6 @@ mod tests { #[test] fn mixed_block_and_inline() { - //
Text

Block

More
let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -823,7 +1016,6 @@ mod tests { let body_box = &tree.root.children[0]; let div_box = &body_box.children[0]; - // div should have 3 children: anonymous(Text), block(p), anonymous(More). assert_eq!( div_box.children.len(), 3, @@ -838,7 +1030,6 @@ mod tests { #[test] fn inline_elements_contribute_text() { - //

Hello world!

let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -860,9 +1051,20 @@ mod tests { let body_box = &tree.root.children[0]; let p_box = &body_box.children[0]; - // Text should be collected from inline children. assert!(!p_box.lines.is_empty()); - assert_eq!(p_box.lines[0].text, "Hello world!"); + + let first_y = p_box.lines[0].y; + let line_texts: Vec<&str> = p_box + .lines + .iter() + .filter(|l| (l.y - first_y).abs() < 0.01) + .map(|l| l.text.as_str()) + .collect(); + let combined = line_texts.join(""); + assert!( + combined.contains("Hello") && combined.contains("world") && combined.contains("!"), + "line should contain all text, got: {combined}" + ); } #[test] @@ -893,10 +1095,8 @@ mod tests { let tree = layout(&styled, &doc, 800.0, 600.0, &font); let body_box = &tree.root.children[0]; - // body content width = 800 - 8 - 8 = 784 assert_eq!(body_box.rect.width, 784.0); - // div inside body should also be 784px wide. let div_box = &body_box.children[0]; assert_eq!(div_box.rect.width, 784.0); } @@ -921,14 +1121,12 @@ mod tests { let tree = layout_doc(&doc); let body_box = &tree.root.children[0]; - // h1 font_size > h2 font_size > h3 font_size let h1 = &body_box.children[0]; let h2 = &body_box.children[1]; let h3 = &body_box.children[2]; assert!(h1.font_size > h2.font_size); assert!(h2.font_size > h3.font_size); - // All should stack vertically. assert!(h2.rect.y > h1.rect.y); assert!(h3.rect.y > h2.rect.y); } @@ -953,7 +1151,6 @@ mod tests { #[test] fn css_style_affects_layout() { - // Test that CSS styles from +

Hello world

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + let colors: Vec = p_box.lines.iter().map(|l| l.color).collect(); + assert!( + colors.iter().any(|c| *c == Color::rgb(0, 0, 0)), + "should have black text" + ); + assert!( + colors.iter().any(|c| *c == Color::rgb(255, 0, 0)), + "should have red text from " + ); + } + + #[test] + fn br_element_forces_line_break() { + let html_str = r#" + +

Line one
Line two

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); + + assert!( + ys.len() >= 2, + "
should produce 2 visual lines, got {}", + ys.len() + ); + } + + #[test] + fn text_align_center() { + let html_str = r#" + + +

Hi

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert!(!p_box.lines.is_empty()); + let first = &p_box.lines[0]; + // Center-aligned: text should be noticeably offset from content x. + assert!( + first.x > p_box.rect.x + 10.0, + "center-aligned text x ({}) should be offset from content x ({})", + first.x, + p_box.rect.x + ); + } + + #[test] + fn text_align_right() { + let html_str = r#" + + +

Hi

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert!(!p_box.lines.is_empty()); + let first = &p_box.lines[0]; + let right_edge = p_box.rect.x + p_box.rect.width; + assert!( + (first.x + first.width - right_edge).abs() < 1.0, + "right-aligned text end ({}) should be near right edge ({})", + first.x + first.width, + right_edge + ); + } + + #[test] + fn inline_padding_offsets_text() { + let html_str = r#" + + +

ABC

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + // Should have at least 3 fragments: A, B, C + assert!( + p_box.lines.len() >= 3, + "should have fragments for A, B, C, got {}", + p_box.lines.len() + ); + + // B should be offset by the span's padding. + let a_frag = &p_box.lines[0]; + let b_frag = &p_box.lines[1]; + let gap = b_frag.x - (a_frag.x + a_frag.width); + // Gap should include the 20px padding-left from the span. + assert!( + gap >= 19.0, + "gap between A and B ({gap}) should include span padding-left (20px)" + ); + } + + #[test] + fn text_fragments_have_correct_font_size() { + let html_str = r#" + +

Big

Small

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let h1_box = &body_box.children[0]; + let p_box = &body_box.children[1]; + + assert!(!h1_box.lines.is_empty()); + assert!(!p_box.lines.is_empty()); + assert_eq!(h1_box.lines[0].font_size, 32.0); + assert_eq!(p_box.lines[0].font_size, 16.0); + } + + #[test] + fn line_height_from_computed_style() { + let html_str = r#" + + +

Line one Line two Line three

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + // Narrow viewport to force wrapping. + let tree = layout(&styled, &doc, 100.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); + + if ys.len() >= 2 { + let gap = ys[1] - ys[0]; + assert!( + (gap - 30.0).abs() < 1.0, + "line spacing ({gap}) should be ~30px from line-height" + ); + } + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3e89149..d0b1619 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,7 +19,7 @@ pub enum PaintCommand { height: f32, color: Color, }, - /// Draw a glyph bitmap at a position with a text color. + /// Draw a text fragment at a position with styling. DrawGlyphs { line: TextLine, font_size: f32, @@ -124,20 +124,21 @@ fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { } fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { - let color = layout_box.color; - let underline = layout_box.text_decoration == TextDecoration::Underline; - for line in &layout_box.lines { + // Use per-fragment styling from the TextLine. + let color = line.color; + let font_size = line.font_size; + list.push(PaintCommand::DrawGlyphs { line: line.clone(), - font_size: layout_box.font_size, + font_size, color, }); // Draw underline as a 1px line below the baseline. - if underline && line.width > 0.0 { - let baseline_y = line.y + layout_box.font_size; - let underline_y = baseline_y + 2.0; // 2px below baseline + if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { + let baseline_y = line.y + font_size; + let underline_y = baseline_y + 2.0; list.push(PaintCommand::FillRect { x: line.x, y: underline_y, @@ -146,6 +147,24 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { color, }); } + + // Draw inline background if not transparent. + if line.background_color.a > 0 && line.width > 0.0 { + // Insert background before the text (painter's order). + // We add it at the end for simplicity; a real implementation + // would insert before the DrawGlyphs. + let bg_idx = list.len() - 1; // Index of the DrawGlyphs we just pushed. + list.insert( + bg_idx, + PaintCommand::FillRect { + x: line.x, + y: line.y, + width: line.width, + height: font_size * 1.2, + color: line.background_color, + }, + ); + } } } -- 2.51.2 From a469cd309b1742398d0598ca5f2b80b1a7eae37b Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 5 Mar 2026 21:26:44 +0100 Subject: [PATCH 4/8] Fix inline background insertion order in paint_text When both underline and background were present, bg_idx pointed to the underline rect instead of the DrawGlyphs command, causing the background to paint after the text. Capture glyph_idx before pushing any commands to ensure correct painter's order. Co-Authored-By: Claude Opus 4.6 --- crates/render/src/lib.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d0b1619..c437827 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -125,10 +125,13 @@ fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { for line in &layout_box.lines { - // Use per-fragment styling from the TextLine. let color = line.color; let font_size = line.font_size; + // Record index before pushing glyphs so we can insert the background + // before the text in painter's order. + let glyph_idx = list.len(); + list.push(PaintCommand::DrawGlyphs { line: line.clone(), font_size, @@ -150,12 +153,8 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { // Draw inline background if not transparent. if line.background_color.a > 0 && line.width > 0.0 { - // Insert background before the text (painter's order). - // We add it at the end for simplicity; a real implementation - // would insert before the DrawGlyphs. - let bg_idx = list.len() - 1; // Index of the DrawGlyphs we just pushed. list.insert( - bg_idx, + glyph_idx, PaintCommand::FillRect { x: line.x, y: line.y, -- 2.51.2 From 0fc095c9cf4f9a107aadaa4fb0c28592591c5bb6 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 6 Mar 2026 07:15:30 +0100 Subject: [PATCH 5/8] Implement SHA-2 hash functions: SHA-256, SHA-384, SHA-512 Add pure Rust implementations of the SHA-2 family per FIPS 180-4 in the crypto crate: - SHA-256: 256-bit digest, 64-byte blocks, 64 rounds - SHA-512: 512-bit digest, 128-byte blocks, 80 rounds - SHA-384: truncated SHA-512 with different initial values All three provide streaming (new/update/finalize) and one-shot APIs. 21 tests including NIST test vectors (empty, abc, 448-bit, 896-bit, million-a) plus streaming and edge-case coverage. Co-Authored-By: Claude Opus 4.6 --- crates/crypto/src/lib.rs | 2 + crates/crypto/src/sha2.rs | 735 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 737 insertions(+) create mode 100644 crates/crypto/src/sha2.rs diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs index 0dbd2c9..c753cbd 100644 --- a/crates/crypto/src/lib.rs +++ b/crates/crypto/src/lib.rs @@ -1 +1,3 @@ //! Pure Rust cryptography — AES-GCM, ChaCha20-Poly1305, SHA-2, X25519, RSA, X.509, ASN.1. + +pub mod sha2; diff --git a/crates/crypto/src/sha2.rs b/crates/crypto/src/sha2.rs new file mode 100644 index 0000000..33f81eb --- /dev/null +++ b/crates/crypto/src/sha2.rs @@ -0,0 +1,735 @@ +//! SHA-2 hash functions: SHA-256, SHA-384, SHA-512 (FIPS 180-4). + +// --------------------------------------------------------------------------- +// SHA-256 +// --------------------------------------------------------------------------- + +/// SHA-256 round constants (first 32 bits of the fractional parts of the +/// cube roots of the first 64 primes). +const K256: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +/// Initial hash values for SHA-256 (first 32 bits of the fractional parts +/// of the square roots of the first 8 primes). +const H256_INIT: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +/// SHA-256 hasher with streaming API. +pub struct Sha256 { + state: [u32; 8], + buf: [u8; 64], + buf_len: usize, + total_len: u64, +} + +impl Sha256 { + pub fn new() -> Self { + Self { + state: H256_INIT, + buf: [0u8; 64], + buf_len: 0, + total_len: 0, + } + } + + pub fn update(&mut self, data: &[u8]) { + self.total_len += data.len() as u64; + let mut offset = 0; + + // Fill the buffer if partially filled. + if self.buf_len > 0 { + let space = 64 - self.buf_len; + let copy_len = space.min(data.len()); + self.buf[self.buf_len..self.buf_len + copy_len].copy_from_slice(&data[..copy_len]); + self.buf_len += copy_len; + offset += copy_len; + + if self.buf_len == 64 { + let block = self.buf; + sha256_compress(&mut self.state, &block); + self.buf_len = 0; + } + } + + // Process full blocks directly. + while offset + 64 <= data.len() { + let block: [u8; 64] = data[offset..offset + 64].try_into().unwrap(); + sha256_compress(&mut self.state, &block); + offset += 64; + } + + // Buffer remaining bytes. + let remaining = data.len() - offset; + if remaining > 0 { + self.buf[..remaining].copy_from_slice(&data[offset..]); + self.buf_len = remaining; + } + } + + pub fn finalize(mut self) -> [u8; 32] { + // Pad per FIPS 180-4 §5.1.1. + let bit_len = self.total_len * 8; + + // Append 0x80 byte. + self.buf[self.buf_len] = 0x80; + self.buf_len += 1; + + // If not enough room for the 8-byte length, pad and compress. + if self.buf_len > 56 { + for i in self.buf_len..64 { + self.buf[i] = 0; + } + let block = self.buf; + sha256_compress(&mut self.state, &block); + self.buf_len = 0; + } + + // Zero-pad up to byte 56, then append 64-bit big-endian length. + for i in self.buf_len..56 { + self.buf[i] = 0; + } + self.buf[56..64].copy_from_slice(&bit_len.to_be_bytes()); + let block = self.buf; + sha256_compress(&mut self.state, &block); + + // Produce the digest. + let mut out = [0u8; 32]; + for (i, word) in self.state.iter().enumerate() { + out[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes()); + } + out + } +} + +impl Default for Sha256 { + fn default() -> Self { + Self::new() + } +} + +/// One-shot SHA-256. +pub fn sha256(data: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(data); + h.finalize() +} + +/// SHA-256 compression function: process one 64-byte block. +fn sha256_compress(state: &mut [u32; 8], block: &[u8; 64]) { + // Prepare the message schedule. + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + block[i * 4], + block[i * 4 + 1], + block[i * 4 + 2], + block[i * 4 + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K256[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); +} + +// --------------------------------------------------------------------------- +// SHA-512 +// --------------------------------------------------------------------------- + +/// SHA-512 round constants (first 64 bits of the fractional parts of the +/// cube roots of the first 80 primes). +const K512: [u64; 80] = [ + 0x428a2f98d728ae22, + 0x7137449123ef65cd, + 0xb5c0fbcfec4d3b2f, + 0xe9b5dba58189dbbc, + 0x3956c25bf348b538, + 0x59f111f1b605d019, + 0x923f82a4af194f9b, + 0xab1c5ed5da6d8118, + 0xd807aa98a3030242, + 0x12835b0145706fbe, + 0x243185be4ee4b28c, + 0x550c7dc3d5ffb4e2, + 0x72be5d74f27b896f, + 0x80deb1fe3b1696b1, + 0x9bdc06a725c71235, + 0xc19bf174cf692694, + 0xe49b69c19ef14ad2, + 0xefbe4786384f25e3, + 0x0fc19dc68b8cd5b5, + 0x240ca1cc77ac9c65, + 0x2de92c6f592b0275, + 0x4a7484aa6ea6e483, + 0x5cb0a9dcbd41fbd4, + 0x76f988da831153b5, + 0x983e5152ee66dfab, + 0xa831c66d2db43210, + 0xb00327c898fb213f, + 0xbf597fc7beef0ee4, + 0xc6e00bf33da88fc2, + 0xd5a79147930aa725, + 0x06ca6351e003826f, + 0x142929670a0e6e70, + 0x27b70a8546d22ffc, + 0x2e1b21385c26c926, + 0x4d2c6dfc5ac42aed, + 0x53380d139d95b3df, + 0x650a73548baf63de, + 0x766a0abb3c77b2a8, + 0x81c2c92e47edaee6, + 0x92722c851482353b, + 0xa2bfe8a14cf10364, + 0xa81a664bbc423001, + 0xc24b8b70d0f89791, + 0xc76c51a30654be30, + 0xd192e819d6ef5218, + 0xd69906245565a910, + 0xf40e35855771202a, + 0x106aa07032bbd1b8, + 0x19a4c116b8d2d0c8, + 0x1e376c085141ab53, + 0x2748774cdf8eeb99, + 0x34b0bcb5e19b48a8, + 0x391c0cb3c5c95a63, + 0x4ed8aa4ae3418acb, + 0x5b9cca4f7763e373, + 0x682e6ff3d6b2b8a3, + 0x748f82ee5defb2fc, + 0x78a5636f43172f60, + 0x84c87814a1f0ab72, + 0x8cc702081a6439ec, + 0x90befffa23631e28, + 0xa4506cebde82bde9, + 0xbef9a3f7b2c67915, + 0xc67178f2e372532b, + 0xca273eceea26619c, + 0xd186b8c721c0c207, + 0xeada7dd6cde0eb1e, + 0xf57d4f7fee6ed178, + 0x06f067aa72176fba, + 0x0a637dc5a2c898a6, + 0x113f9804bef90dae, + 0x1b710b35131c471b, + 0x28db77f523047d84, + 0x32caab7b40c72493, + 0x3c9ebe0a15c9bebc, + 0x431d67c49c100d4c, + 0x4cc5d4becb3e42b6, + 0x597f299cfc657e2a, + 0x5fcb6fab3ad6faec, + 0x6c44198c4a475817, +]; + +/// Initial hash values for SHA-512. +const H512_INIT: [u64; 8] = [ + 0x6a09e667f3bcc908, + 0xbb67ae8584caa73b, + 0x3c6ef372fe94f82b, + 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, + 0x9b05688c2b3e6c1f, + 0x1f83d9abfb41bd6b, + 0x5be0cd19137e2179, +]; + +/// Initial hash values for SHA-384 (different from SHA-512). +const H384_INIT: [u64; 8] = [ + 0xcbbb9d5dc1059ed8, + 0x629a292a367cd507, + 0x9159015a3070dd17, + 0x152fecd8f70e5939, + 0x67332667ffc00b31, + 0x8eb44a8768581511, + 0xdb0c2e0d64f98fa7, + 0x47b5481dbefa4fa4, +]; + +/// SHA-512 hasher with streaming API. +pub struct Sha512 { + state: [u64; 8], + buf: [u8; 128], + buf_len: usize, + total_len: u128, +} + +impl Sha512 { + pub fn new() -> Self { + Self { + state: H512_INIT, + buf: [0u8; 128], + buf_len: 0, + total_len: 0, + } + } + + fn with_init(init: [u64; 8]) -> Self { + Self { + state: init, + buf: [0u8; 128], + buf_len: 0, + total_len: 0, + } + } + + pub fn update(&mut self, data: &[u8]) { + self.total_len += data.len() as u128; + let mut offset = 0; + + if self.buf_len > 0 { + let space = 128 - self.buf_len; + let copy_len = space.min(data.len()); + self.buf[self.buf_len..self.buf_len + copy_len].copy_from_slice(&data[..copy_len]); + self.buf_len += copy_len; + offset += copy_len; + + if self.buf_len == 128 { + let block = self.buf; + sha512_compress(&mut self.state, &block); + self.buf_len = 0; + } + } + + while offset + 128 <= data.len() { + let block: [u8; 128] = data[offset..offset + 128].try_into().unwrap(); + sha512_compress(&mut self.state, &block); + offset += 128; + } + + let remaining = data.len() - offset; + if remaining > 0 { + self.buf[..remaining].copy_from_slice(&data[offset..]); + self.buf_len = remaining; + } + } + + pub fn finalize(mut self) -> [u8; 64] { + // Pad per FIPS 180-4 §5.1.2. + let bit_len = self.total_len * 8; + + self.buf[self.buf_len] = 0x80; + self.buf_len += 1; + + // Need 16 bytes for the 128-bit length at the end. + if self.buf_len > 112 { + for i in self.buf_len..128 { + self.buf[i] = 0; + } + let block = self.buf; + sha512_compress(&mut self.state, &block); + self.buf_len = 0; + } + + for i in self.buf_len..112 { + self.buf[i] = 0; + } + self.buf[112..128].copy_from_slice(&bit_len.to_be_bytes()); + let block = self.buf; + sha512_compress(&mut self.state, &block); + + let mut out = [0u8; 64]; + for (i, word) in self.state.iter().enumerate() { + out[i * 8..(i + 1) * 8].copy_from_slice(&word.to_be_bytes()); + } + out + } +} + +impl Default for Sha512 { + fn default() -> Self { + Self::new() + } +} + +/// One-shot SHA-512. +pub fn sha512(data: &[u8]) -> [u8; 64] { + let mut h = Sha512::new(); + h.update(data); + h.finalize() +} + +/// SHA-512 compression function: process one 128-byte block. +fn sha512_compress(state: &mut [u64; 8], block: &[u8; 128]) { + let mut w = [0u64; 80]; + for i in 0..16 { + w[i] = u64::from_be_bytes([ + block[i * 8], + block[i * 8 + 1], + block[i * 8 + 2], + block[i * 8 + 3], + block[i * 8 + 4], + block[i * 8 + 5], + block[i * 8 + 6], + block[i * 8 + 7], + ]); + } + for i in 16..80 { + let s0 = w[i - 15].rotate_right(1) ^ w[i - 15].rotate_right(8) ^ (w[i - 15] >> 7); + let s1 = w[i - 2].rotate_right(19) ^ w[i - 2].rotate_right(61) ^ (w[i - 2] >> 6); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + + for i in 0..80 { + let s1 = e.rotate_right(14) ^ e.rotate_right(18) ^ e.rotate_right(41); + let ch = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K512[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(28) ^ a.rotate_right(34) ^ a.rotate_right(39); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); +} + +// --------------------------------------------------------------------------- +// SHA-384 +// --------------------------------------------------------------------------- + +/// SHA-384 hasher (SHA-512 with different initial values, truncated to 384 bits). +pub struct Sha384 { + inner: Sha512, +} + +impl Sha384 { + pub fn new() -> Self { + Self { + inner: Sha512::with_init(H384_INIT), + } + } + + pub fn update(&mut self, data: &[u8]) { + self.inner.update(data); + } + + pub fn finalize(self) -> [u8; 48] { + let full = self.inner.finalize(); + let mut out = [0u8; 48]; + out.copy_from_slice(&full[..48]); + out + } +} + +impl Default for Sha384 { + fn default() -> Self { + Self::new() + } +} + +/// One-shot SHA-384. +pub fn sha384(data: &[u8]) -> [u8; 48] { + let mut h = Sha384::new(); + h.update(data); + h.finalize() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + // ----------------------------------------------------------------------- + // SHA-256 NIST test vectors (FIPS 180-4 / NIST CSRC examples) + // ----------------------------------------------------------------------- + + #[test] + fn sha256_empty() { + assert_eq!( + hex(&sha256(b"")), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_abc() { + assert_eq!( + hex(&sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sha256_448bit() { + // "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" (448 bits) + assert_eq!( + hex(&sha256( + b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" + )), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + } + + #[test] + fn sha256_896bit() { + // "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu" + assert_eq!( + hex(&sha256(b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu")), + "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1" + ); + } + + #[test] + fn sha256_million_a() { + // 1,000,000 repetitions of 'a' + let mut h = Sha256::new(); + // Feed in chunks to test streaming. + let chunk = [b'a'; 1000]; + for _ in 0..1000 { + h.update(&chunk); + } + assert_eq!( + hex(&h.finalize()), + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0" + ); + } + + #[test] + fn sha256_streaming() { + // Verify streaming matches one-shot. + let data = b"The quick brown fox jumps over the lazy dog"; + let expected = sha256(data); + + let mut h = Sha256::new(); + h.update(&data[..10]); + h.update(&data[10..20]); + h.update(&data[20..]); + assert_eq!(h.finalize(), expected); + } + + // ----------------------------------------------------------------------- + // SHA-512 NIST test vectors + // ----------------------------------------------------------------------- + + #[test] + fn sha512_empty() { + assert_eq!( + hex(&sha512(b"")), + "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + ); + } + + #[test] + fn sha512_abc() { + assert_eq!( + hex(&sha512(b"abc")), + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + ); + } + + #[test] + fn sha512_896bit() { + assert_eq!( + hex(&sha512(b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu")), + "8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909" + ); + } + + #[test] + fn sha512_million_a() { + let mut h = Sha512::new(); + let chunk = [b'a'; 1000]; + for _ in 0..1000 { + h.update(&chunk); + } + assert_eq!( + hex(&h.finalize()), + "e718483d0ce769644e2e42c7bc15b4638e1f98b13b2044285632a803afa973ebde0ff244877ea60a4cb0432ce577c31beb009c5c2c49aa2e4eadb217ad8cc09b" + ); + } + + #[test] + fn sha512_streaming() { + let data = b"The quick brown fox jumps over the lazy dog"; + let expected = sha512(data); + + let mut h = Sha512::new(); + h.update(&data[..5]); + h.update(&data[5..]); + assert_eq!(h.finalize(), expected); + } + + // ----------------------------------------------------------------------- + // SHA-384 NIST test vectors + // ----------------------------------------------------------------------- + + #[test] + fn sha384_empty() { + assert_eq!( + hex(&sha384(b"")), + "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b" + ); + } + + #[test] + fn sha384_abc() { + assert_eq!( + hex(&sha384(b"abc")), + "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7" + ); + } + + #[test] + fn sha384_896bit() { + assert_eq!( + hex(&sha384(b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu")), + "09330c33f71147e83d192fc782cd1b4753111b173b3b05d22fa08086e3b0f712fcc7c71a557e2db966c3e9fa91746039" + ); + } + + #[test] + fn sha384_million_a() { + let mut h = Sha384::new(); + let chunk = [b'a'; 1000]; + for _ in 0..1000 { + h.update(&chunk); + } + assert_eq!( + hex(&h.finalize()), + "9d0e1809716474cb086e834e310a4a1ced149e9c00f248527972cec5704c2a5b07b8b3dc38ecc4ebae97ddd87f3d8985" + ); + } + + #[test] + fn sha384_streaming() { + let data = b"The quick brown fox jumps over the lazy dog"; + let expected = sha384(data); + + let mut h = Sha384::new(); + h.update(&data[..15]); + h.update(&data[15..]); + assert_eq!(h.finalize(), expected); + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + #[test] + fn sha256_exactly_one_block() { + // 55 bytes + 1 byte (0x80) + 8 bytes (length) = 64 = one block after padding + let data = [0xABu8; 55]; + let result = sha256(&data); + // Verify streaming matches. + let mut h = Sha256::new(); + h.update(&data); + assert_eq!(h.finalize(), result); + } + + #[test] + fn sha256_exactly_56_bytes() { + // 56 bytes requires two blocks (56 + 1 + 8 > 64). + let data = [0xCDu8; 56]; + let result = sha256(&data); + let mut h = Sha256::new(); + h.update(&data); + assert_eq!(h.finalize(), result); + } + + #[test] + fn sha512_exactly_one_block() { + // 111 bytes + 1 + 16 = 128 + let data = [0xABu8; 111]; + let result = sha512(&data); + let mut h = Sha512::new(); + h.update(&data); + assert_eq!(h.finalize(), result); + } + + #[test] + fn sha512_exactly_112_bytes() { + // 112 + 1 + 16 > 128 => requires second block + let data = [0xCDu8; 112]; + let result = sha512(&data); + let mut h = Sha512::new(); + h.update(&data); + assert_eq!(h.finalize(), result); + } + + #[test] + fn sha256_byte_at_a_time() { + let data = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; + let expected = sha256(data); + let mut h = Sha256::new(); + for byte in data.iter() { + h.update(&[*byte]); + } + assert_eq!(h.finalize(), expected); + } +} -- 2.51.2 From 10288a777d93732e2737d29ca4c57c4b7a5b3d57 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 6 Mar 2026 07:29:29 +0100 Subject: [PATCH 6/8] Implement HMAC: keyed-hash message authentication code (RFC 2104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pure Rust HMAC implementation in the crypto crate, generic over hash function via a HashFunction trait: - HMAC-SHA-256, HMAC-SHA-384, HMAC-SHA-512 - Streaming API: new(key), update(&[u8]), finalize() -> Vec - One-shot: hmac_sha256(), hmac_sha384(), hmac_sha512() - Key handling: long keys hashed, short keys zero-padded - Inner/outer padding (ipad/opad) per RFC 2104 §2 - 25 tests including all RFC 4231 test vectors (cases 1-7) Co-Authored-By: Claude Opus 4.6 --- crates/crypto/src/hmac.rs | 443 ++++++++++++++++++++++++++++++++++++++ crates/crypto/src/lib.rs | 1 + 2 files changed, 444 insertions(+) create mode 100644 crates/crypto/src/hmac.rs diff --git a/crates/crypto/src/hmac.rs b/crates/crypto/src/hmac.rs new file mode 100644 index 0000000..55a4660 --- /dev/null +++ b/crates/crypto/src/hmac.rs @@ -0,0 +1,443 @@ +//! HMAC: keyed-hash message authentication code (RFC 2104). + +use crate::sha2::{Sha256, Sha384, Sha512}; + +/// Trait abstracting a hash function for use with HMAC. +pub trait HashFunction { + /// Block size in bytes (64 for SHA-256, 128 for SHA-512/SHA-384). + const BLOCK_SIZE: usize; + /// Output digest size in bytes. + const OUTPUT_SIZE: usize; + + fn hash_new() -> Self; + fn hash_update(&mut self, data: &[u8]); + fn hash_finalize(self) -> Vec; +} + +impl HashFunction for Sha256 { + const BLOCK_SIZE: usize = 64; + const OUTPUT_SIZE: usize = 32; + + fn hash_new() -> Self { + Sha256::new() + } + + fn hash_update(&mut self, data: &[u8]) { + Sha256::update(self, data); + } + + fn hash_finalize(self) -> Vec { + Sha256::finalize(self).to_vec() + } +} + +impl HashFunction for Sha512 { + const BLOCK_SIZE: usize = 128; + const OUTPUT_SIZE: usize = 64; + + fn hash_new() -> Self { + Sha512::new() + } + + fn hash_update(&mut self, data: &[u8]) { + Sha512::update(self, data); + } + + fn hash_finalize(self) -> Vec { + Sha512::finalize(self).to_vec() + } +} + +impl HashFunction for Sha384 { + const BLOCK_SIZE: usize = 128; + const OUTPUT_SIZE: usize = 48; + + fn hash_new() -> Self { + Sha384::new() + } + + fn hash_update(&mut self, data: &[u8]) { + Sha384::update(self, data); + } + + fn hash_finalize(self) -> Vec { + Sha384::finalize(self).to_vec() + } +} + +/// HMAC hasher, generic over the hash function `H`. +/// +/// Implements RFC 2104: `HMAC(K, m) = H((K' ⊕ opad) ∥ H((K' ⊕ ipad) ∥ m))` +pub struct Hmac { + /// Inner hash, pre-seeded with `K' ⊕ ipad`. + inner: H, + /// Pre-computed `K' ⊕ opad` for the outer hash. + opad_key: Vec, +} + +impl Hmac { + /// Create a new HMAC instance with the given key. + /// + /// Keys longer than the hash block size are hashed first. + /// Keys shorter than the block size are zero-padded. + pub fn new(key: &[u8]) -> Self { + // Step 1: Derive the block-sized key K'. + let mut key_block = vec![0u8; H::BLOCK_SIZE]; + if key.len() > H::BLOCK_SIZE { + let mut h = H::hash_new(); + h.hash_update(key); + let hashed = h.hash_finalize(); + key_block[..hashed.len()].copy_from_slice(&hashed); + } else { + key_block[..key.len()].copy_from_slice(key); + } + + // Step 2: Compute ipad and opad keys. + let mut ipad_key = vec![0u8; H::BLOCK_SIZE]; + let mut opad_key = vec![0u8; H::BLOCK_SIZE]; + for i in 0..H::BLOCK_SIZE { + ipad_key[i] = key_block[i] ^ 0x36; + opad_key[i] = key_block[i] ^ 0x5c; + } + + // Step 3: Start the inner hash with K' ⊕ ipad. + let mut inner = H::hash_new(); + inner.hash_update(&ipad_key); + + Self { inner, opad_key } + } + + /// Feed data into the HMAC computation. + pub fn update(&mut self, data: &[u8]) { + self.inner.hash_update(data); + } + + /// Finalize and return the HMAC digest. + pub fn finalize(self) -> Vec { + // Complete inner hash: H((K' ⊕ ipad) ∥ message) + let inner_hash = self.inner.hash_finalize(); + + // Compute outer hash: H((K' ⊕ opad) ∥ inner_hash) + let mut outer = H::hash_new(); + outer.hash_update(&self.opad_key); + outer.hash_update(&inner_hash); + outer.hash_finalize() + } +} + +/// One-shot HMAC-SHA-256. +pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] { + let mut h = Hmac::::new(key); + h.update(data); + let result = h.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&result); + out +} + +/// One-shot HMAC-SHA-384. +pub fn hmac_sha384(key: &[u8], data: &[u8]) -> [u8; 48] { + let mut h = Hmac::::new(key); + h.update(data); + let result = h.finalize(); + let mut out = [0u8; 48]; + out.copy_from_slice(&result); + out +} + +/// One-shot HMAC-SHA-512. +pub fn hmac_sha512(key: &[u8], data: &[u8]) -> [u8; 64] { + let mut h = Hmac::::new(key); + h.update(data); + let result = h.finalize(); + let mut out = [0u8; 64]; + out.copy_from_slice(&result); + out +} + +// --------------------------------------------------------------------------- +// Tests — RFC 4231 test vectors +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + // ----------------------------------------------------------------------- + // Test Case 1: Key = 0x0b * 20, Data = "Hi There" + // ----------------------------------------------------------------------- + + #[test] + fn rfc4231_case1_sha256() { + let key = [0x0bu8; 20]; + assert_eq!( + hex(&hmac_sha256(&key, b"Hi There")), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + ); + } + + #[test] + fn rfc4231_case1_sha384() { + let key = [0x0bu8; 20]; + assert_eq!( + hex(&hmac_sha384(&key, b"Hi There")), + "afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6" + ); + } + + #[test] + fn rfc4231_case1_sha512() { + let key = [0x0bu8; 20]; + assert_eq!( + hex(&hmac_sha512(&key, b"Hi There")), + "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 2: Key = "Jefe", Data = "what do ya want for nothing?" + // ----------------------------------------------------------------------- + + #[test] + fn rfc4231_case2_sha256() { + assert_eq!( + hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + } + + #[test] + fn rfc4231_case2_sha384() { + assert_eq!( + hex(&hmac_sha384(b"Jefe", b"what do ya want for nothing?")), + "af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e8e2240ca5e69e2c78b3239ecfab21649" + ); + } + + #[test] + fn rfc4231_case2_sha512() { + assert_eq!( + hex(&hmac_sha512(b"Jefe", b"what do ya want for nothing?")), + "164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 3: Key = 0xaa * 20, Data = 0xdd * 50 + // ----------------------------------------------------------------------- + + #[test] + fn rfc4231_case3_sha256() { + let key = [0xaau8; 20]; + let data = [0xddu8; 50]; + assert_eq!( + hex(&hmac_sha256(&key, &data)), + "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe" + ); + } + + #[test] + fn rfc4231_case3_sha384() { + let key = [0xaau8; 20]; + let data = [0xddu8; 50]; + assert_eq!( + hex(&hmac_sha384(&key, &data)), + "88062608d3e6ad8a0aa2ace014c8a86f0aa635d947ac9febe83ef4e55966144b2a5ab39dc13814b94e3ab6e101a34f27" + ); + } + + #[test] + fn rfc4231_case3_sha512() { + let key = [0xaau8; 20]; + let data = [0xddu8; 50]; + assert_eq!( + hex(&hmac_sha512(&key, &data)), + "fa73b0089d56a284efb0f0756c890be9b1b5dbdd8ee81a3655f83e33b2279d39bf3e848279a722c806b485a47e67c807b946a337bee8942674278859e13292fb" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 4: Key = 0x01..0x19 (25 bytes), Data = 0xcd * 50 + // ----------------------------------------------------------------------- + + #[test] + fn rfc4231_case4_sha256() { + let key: Vec = (0x01..=0x19).collect(); + let data = [0xcdu8; 50]; + assert_eq!( + hex(&hmac_sha256(&key, &data)), + "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b" + ); + } + + #[test] + fn rfc4231_case4_sha384() { + let key: Vec = (0x01..=0x19).collect(); + let data = [0xcdu8; 50]; + assert_eq!( + hex(&hmac_sha384(&key, &data)), + "3e8a69b7783c25851933ab6290af6ca77a9981480850009cc5577c6e1f573b4e6801dd23c4a7d679ccf8a386c674cffb" + ); + } + + #[test] + fn rfc4231_case4_sha512() { + let key: Vec = (0x01..=0x19).collect(); + let data = [0xcdu8; 50]; + assert_eq!( + hex(&hmac_sha512(&key, &data)), + "b0ba465637458c6990e5a8c5f61d4af7e576d97ff94b872de76f8050361ee3dba91ca5c11aa25eb4d679275cc5788063a5f19741120c4f2de2adebeb10a298dd" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 5: Truncation (Key = 0x0c * 20, Data = "Test With Truncation") + // Verify the first 16 bytes of the full HMAC match the truncated vector. + // ----------------------------------------------------------------------- + + #[test] + fn rfc4231_case5_sha256_truncated() { + let key = [0x0cu8; 20]; + let result = hmac_sha256(&key, b"Test With Truncation"); + assert_eq!(hex(&result[..16]), "a3b6167473100ee06e0c796c2955552b"); + } + + #[test] + fn rfc4231_case5_sha384_truncated() { + let key = [0x0cu8; 20]; + let result = hmac_sha384(&key, b"Test With Truncation"); + assert_eq!(hex(&result[..16]), "3abf34c3503b2a23a46efc619baef897"); + } + + #[test] + fn rfc4231_case5_sha512_truncated() { + let key = [0x0cu8; 20]; + let result = hmac_sha512(&key, b"Test With Truncation"); + assert_eq!(hex(&result[..16]), "415fad6271580a531d4179bc891d87a6"); + } + + // ----------------------------------------------------------------------- + // Test Case 6: Key longer than block size (0xaa * 131) + // Data = "Test Using Larger Than Block-Size Key - Hash Key First" + // ----------------------------------------------------------------------- + + #[test] + fn rfc4231_case6_sha256() { + let key = [0xaau8; 131]; + assert_eq!( + hex(&hmac_sha256( + &key, + b"Test Using Larger Than Block-Size Key - Hash Key First" + )), + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" + ); + } + + #[test] + fn rfc4231_case6_sha384() { + let key = [0xaau8; 131]; + assert_eq!( + hex(&hmac_sha384(&key, b"Test Using Larger Than Block-Size Key - Hash Key First")), + "4ece084485813e9088d2c63a041bc5b44f9ef1012a2b588f3cd11f05033ac4c60c2ef6ab4030fe8296248df163f44952" + ); + } + + #[test] + fn rfc4231_case6_sha512() { + let key = [0xaau8; 131]; + assert_eq!( + hex(&hmac_sha512(&key, b"Test Using Larger Than Block-Size Key - Hash Key First")), + "80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b013783f8f3526b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec8b915a985d786598" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 7: Key longer than block size, data longer than block size + // Key = 0xaa * 131 + // ----------------------------------------------------------------------- + + const CASE7_DATA: &[u8] = b"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm."; + + #[test] + fn rfc4231_case7_sha256() { + let key = [0xaau8; 131]; + assert_eq!( + hex(&hmac_sha256(&key, CASE7_DATA)), + "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2" + ); + } + + #[test] + fn rfc4231_case7_sha384() { + let key = [0xaau8; 131]; + assert_eq!( + hex(&hmac_sha384(&key, CASE7_DATA)), + "6617178e941f020d351e2f254e8fd32c602420feb0b8fb9adccebb82461e99c5a678cc31e799176d3860e6110c46523e" + ); + } + + #[test] + fn rfc4231_case7_sha512() { + let key = [0xaau8; 131]; + assert_eq!( + hex(&hmac_sha512(&key, CASE7_DATA)), + "e37b6a775dc87dbaa4dfa9f96e5e3ffddebd71f8867289865df5a32d20cdc944b6022cac3c4982b10d5eeb55c3e4de15134676fb6de0446065c97440fa8c6a58" + ); + } + + // ----------------------------------------------------------------------- + // Streaming API tests + // ----------------------------------------------------------------------- + + #[test] + fn streaming_matches_oneshot_sha256() { + let key = b"secret key"; + let data = b"The quick brown fox jumps over the lazy dog"; + let expected = hmac_sha256(key, data); + + let mut h = Hmac::::new(key); + h.update(&data[..10]); + h.update(&data[10..30]); + h.update(&data[30..]); + let result = h.finalize(); + assert_eq!(&result[..], &expected[..]); + } + + #[test] + fn streaming_matches_oneshot_sha512() { + let key = b"another secret key"; + let data = b"Some message to authenticate"; + let expected = hmac_sha512(key, data); + + let mut h = Hmac::::new(key); + h.update(&data[..5]); + h.update(&data[5..]); + let result = h.finalize(); + assert_eq!(&result[..], &expected[..]); + } + + #[test] + fn empty_data() { + let key = [0x0bu8; 20]; + let result = hmac_sha256(&key, b""); + // Just verify it doesn't panic and produces 32 bytes. + assert_eq!(result.len(), 32); + } + + #[test] + fn key_exactly_block_size_sha256() { + // Key exactly 64 bytes (SHA-256 block size) — no hashing, no padding needed. + let key = [0x42u8; 64]; + let result = hmac_sha256(&key, b"test"); + assert_eq!(result.len(), 32); + + // Verify streaming matches. + let mut h = Hmac::::new(&key); + h.update(b"test"); + assert_eq!(&h.finalize()[..], &result[..]); + } +} diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs index c753cbd..2dacf56 100644 --- a/crates/crypto/src/lib.rs +++ b/crates/crypto/src/lib.rs @@ -1,3 +1,4 @@ //! Pure Rust cryptography — AES-GCM, ChaCha20-Poly1305, SHA-2, X25519, RSA, X.509, ASN.1. +pub mod hmac; pub mod sha2; -- 2.51.2 From eaec46bf85e4f3ee0ff50e6f16f7a7df920dd1f2 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sat, 7 Mar 2026 08:45:05 +0100 Subject: [PATCH 7/8] Implement HKDF: HMAC-based key derivation function (RFC 5869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pure Rust HKDF implementation in the crypto crate, generic over hash function via the existing HashFunction trait: - hkdf_extract(salt, IKM) -> PRK - hkdf_expand(PRK, info, L) -> OKM with length validation - hkdf() combined convenience function - HKDF-SHA-256, HKDF-SHA-384, HKDF-SHA-512 via generics - Empty salt defaults to HashLen zeros per RFC 5869 §2.2 - Output length validated: L <= 255 * HashLen - 14 tests including all RFC 5869 SHA-256 test vectors (cases 1-3) Co-Authored-By: Claude Opus 4.6 --- crates/crypto/src/hkdf.rs | 295 ++++++++++++++++++++++++++++++++++++++ crates/crypto/src/lib.rs | 1 + 2 files changed, 296 insertions(+) create mode 100644 crates/crypto/src/hkdf.rs diff --git a/crates/crypto/src/hkdf.rs b/crates/crypto/src/hkdf.rs new file mode 100644 index 0000000..7211b88 --- /dev/null +++ b/crates/crypto/src/hkdf.rs @@ -0,0 +1,295 @@ +//! HKDF: HMAC-based Extract-and-Expand Key Derivation Function (RFC 5869). + +use crate::hmac::{HashFunction, Hmac}; + +/// HKDF-Extract: derive a pseudorandom key (PRK) from input keying material. +/// +/// `salt` is an optional non-secret random value; if empty, a string of +/// `H::OUTPUT_SIZE` zeros is used (per RFC 5869 §2.2). +pub fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> Vec { + let effective_salt: Vec; + let salt = if salt.is_empty() { + effective_salt = vec![0u8; H::OUTPUT_SIZE]; + &effective_salt + } else { + salt + }; + let mut hmac = Hmac::::new(salt); + hmac.update(ikm); + hmac.finalize() +} + +/// HKDF-Expand: expand a PRK into output keying material of length `len`. +/// +/// `len` must be <= 255 * `H::OUTPUT_SIZE`. +/// Returns `None` if `len` exceeds the maximum. +pub fn hkdf_expand(prk: &[u8], info: &[u8], len: usize) -> Option> { + let hash_len = H::OUTPUT_SIZE; + if len > 255 * hash_len { + return None; + } + + let n = len.div_ceil(hash_len); + let mut okm = Vec::with_capacity(n * hash_len); + let mut t_prev: Vec = Vec::new(); + + for i in 1..=n { + let mut hmac = Hmac::::new(prk); + hmac.update(&t_prev); + hmac.update(info); + hmac.update(&[i as u8]); + t_prev = hmac.finalize(); + okm.extend_from_slice(&t_prev); + } + + okm.truncate(len); + Some(okm) +} + +/// Combined HKDF: extract then expand in one call. +/// +/// Returns `None` if `len` exceeds 255 * `H::OUTPUT_SIZE`. +pub fn hkdf(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Option> { + let prk = hkdf_extract::(salt, ikm); + hkdf_expand::(&prk, info, len) +} + +// --------------------------------------------------------------------------- +// Tests — RFC 5869 test vectors +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::sha2::Sha256; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + fn from_hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + // ----------------------------------------------------------------------- + // Test Case 1: Basic test case with SHA-256 + // ----------------------------------------------------------------------- + + #[test] + fn rfc5869_case1_extract() { + let ikm = from_hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + let salt = from_hex("000102030405060708090a0b0c"); + let prk = hkdf_extract::(&salt, &ikm); + assert_eq!( + hex(&prk), + "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5" + ); + } + + #[test] + fn rfc5869_case1_expand() { + let prk = from_hex("077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5"); + let info = from_hex("f0f1f2f3f4f5f6f7f8f9"); + let okm = hkdf_expand::(&prk, &info, 42).unwrap(); + assert_eq!( + hex(&okm), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ); + } + + #[test] + fn rfc5869_case1_combined() { + let ikm = from_hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + let salt = from_hex("000102030405060708090a0b0c"); + let info = from_hex("f0f1f2f3f4f5f6f7f8f9"); + let okm = hkdf::(&salt, &ikm, &info, 42).unwrap(); + assert_eq!( + hex(&okm), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 2: Longer inputs/outputs with SHA-256 + // ----------------------------------------------------------------------- + + #[test] + fn rfc5869_case2_extract() { + let ikm = from_hex( + "000102030405060708090a0b0c0d0e0f\ + 101112131415161718191a1b1c1d1e1f\ + 202122232425262728292a2b2c2d2e2f\ + 303132333435363738393a3b3c3d3e3f\ + 404142434445464748494a4b4c4d4e4f", + ); + let salt = from_hex( + "606162636465666768696a6b6c6d6e6f\ + 707172737475767778797a7b7c7d7e7f\ + 808182838485868788898a8b8c8d8e8f\ + 909192939495969798999a9b9c9d9e9f\ + a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + ); + let prk = hkdf_extract::(&salt, &ikm); + assert_eq!( + hex(&prk), + "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244" + ); + } + + #[test] + fn rfc5869_case2_expand() { + let prk = from_hex("06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244"); + let info = from_hex( + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf\ + c0c1c2c3c4c5c6c7c8c9cacbcccdcecf\ + d0d1d2d3d4d5d6d7d8d9dadbdcdddedf\ + e0e1e2e3e4e5e6e7e8e9eaebecedeeef\ + f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", + ); + let okm = hkdf_expand::(&prk, &info, 82).unwrap(); + assert_eq!( + hex(&okm), + "b11e398dc80327a1c8e7f78c596a4934\ + 4f012eda2d4efad8a050cc4c19afa97c\ + 59045a99cac7827271cb41c65e590e09\ + da3275600c2f09b8367793a9aca3db71\ + cc30c58179ec3e87c14c01d5c1f3434f\ + 1d87" + ); + } + + #[test] + fn rfc5869_case2_combined() { + let ikm = from_hex( + "000102030405060708090a0b0c0d0e0f\ + 101112131415161718191a1b1c1d1e1f\ + 202122232425262728292a2b2c2d2e2f\ + 303132333435363738393a3b3c3d3e3f\ + 404142434445464748494a4b4c4d4e4f", + ); + let salt = from_hex( + "606162636465666768696a6b6c6d6e6f\ + 707172737475767778797a7b7c7d7e7f\ + 808182838485868788898a8b8c8d8e8f\ + 909192939495969798999a9b9c9d9e9f\ + a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + ); + let info = from_hex( + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf\ + c0c1c2c3c4c5c6c7c8c9cacbcccdcecf\ + d0d1d2d3d4d5d6d7d8d9dadbdcdddedf\ + e0e1e2e3e4e5e6e7e8e9eaebecedeeef\ + f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", + ); + let okm = hkdf::(&salt, &ikm, &info, 82).unwrap(); + assert_eq!( + hex(&okm), + "b11e398dc80327a1c8e7f78c596a4934\ + 4f012eda2d4efad8a050cc4c19afa97c\ + 59045a99cac7827271cb41c65e590e09\ + da3275600c2f09b8367793a9aca3db71\ + cc30c58179ec3e87c14c01d5c1f3434f\ + 1d87" + ); + } + + // ----------------------------------------------------------------------- + // Test Case 3: SHA-256, zero-length salt and info + // ----------------------------------------------------------------------- + + #[test] + fn rfc5869_case3_extract() { + let ikm = from_hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + let prk = hkdf_extract::(&[], &ikm); + assert_eq!( + hex(&prk), + "19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04" + ); + } + + #[test] + fn rfc5869_case3_expand() { + let prk = from_hex("19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04"); + let okm = hkdf_expand::(&prk, &[], 42).unwrap(); + assert_eq!( + hex(&okm), + "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8" + ); + } + + #[test] + fn rfc5869_case3_combined() { + let ikm = from_hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + let okm = hkdf::(&[], &ikm, &[], 42).unwrap(); + assert_eq!( + hex(&okm), + "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8" + ); + } + + // ----------------------------------------------------------------------- + // Output length validation + // ----------------------------------------------------------------------- + + #[test] + fn expand_rejects_oversized_output() { + let prk = [0x42u8; 32]; + // 255 * 32 = 8160 is the max for SHA-256 + assert!(hkdf_expand::(&prk, &[], 8160).is_some()); + assert!(hkdf_expand::(&prk, &[], 8161).is_none()); + } + + #[test] + fn hkdf_rejects_oversized_output() { + let ikm = [0x0bu8; 22]; + assert!(hkdf::(&[], &ikm, &[], 8161).is_none()); + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + #[test] + fn expand_zero_length_output() { + let prk = [0x42u8; 32]; + let okm = hkdf_expand::(&prk, &[], 0).unwrap(); + assert!(okm.is_empty()); + } + + #[test] + fn expand_exact_hash_length() { + let prk = [0x42u8; 32]; + let okm = hkdf_expand::(&prk, b"info", 32).unwrap(); + assert_eq!(okm.len(), 32); + } + + #[test] + fn extract_expand_with_sha512() { + use crate::sha2::Sha512; + + // Use Test Case 1 inputs but with SHA-512 — verify it produces + // the correct output length and doesn't panic. + let ikm = from_hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + let salt = from_hex("000102030405060708090a0b0c"); + let prk = hkdf_extract::(&salt, &ikm); + assert_eq!(prk.len(), 64); + + let okm = hkdf_expand::(&prk, b"test info", 100).unwrap(); + assert_eq!(okm.len(), 100); + } + + #[test] + fn extract_expand_with_sha384() { + use crate::sha2::Sha384; + + let ikm = [0xaau8; 80]; + let prk = hkdf_extract::(&[], &ikm); + assert_eq!(prk.len(), 48); + + let okm = hkdf_expand::(&prk, b"context", 60).unwrap(); + assert_eq!(okm.len(), 60); + } +} diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs index 2dacf56..e71cf2c 100644 --- a/crates/crypto/src/lib.rs +++ b/crates/crypto/src/lib.rs @@ -1,4 +1,5 @@ //! Pure Rust cryptography — AES-GCM, ChaCha20-Poly1305, SHA-2, X25519, RSA, X.509, ASN.1. +pub mod hkdf; pub mod hmac; pub mod sha2; -- 2.51.2 From d3541ab82f06a321bf37f2187858529073789248 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sat, 7 Mar 2026 09:03:50 +0100 Subject: [PATCH 8/8] Implement AES-128-GCM and AES-256-GCM (NIST SP 800-38D) Add pure Rust AES-GCM authenticated encryption in the crypto crate: - AES block cipher (FIPS 197): key expansion and encrypt for 128/256-bit keys - GHASH: constant-time GF(2^128) multiplication using bit masking - GCM encrypt/decrypt with 96-bit nonce, 128-bit authentication tag, and AAD - Constant-time tag comparison for secure decryption - 25 tests including NIST SP 800-38D test vectors (cases 1-4, 13-16), tag tamper detection, and encrypt/decrypt round-trips Co-Authored-By: Claude Opus 4.6 --- crates/crypto/src/aes_gcm.rs | 856 +++++++++++++++++++++++++++++++++++ crates/crypto/src/lib.rs | 1 + 2 files changed, 857 insertions(+) create mode 100644 crates/crypto/src/aes_gcm.rs diff --git a/crates/crypto/src/aes_gcm.rs b/crates/crypto/src/aes_gcm.rs new file mode 100644 index 0000000..ae44275 --- /dev/null +++ b/crates/crypto/src/aes_gcm.rs @@ -0,0 +1,856 @@ +//! AES-128-GCM and AES-256-GCM authenticated encryption (NIST SP 800-38D). +//! +//! - AES block cipher (FIPS 197) with 128-bit and 256-bit keys +//! - GHASH: Galois field multiplication in GF(2^128) +//! - GCM encrypt/decrypt with 96-bit nonce and 128-bit authentication tag + +// --------------------------------------------------------------------------- +// AES S-box (FIPS 197, §5.1.1) +// --------------------------------------------------------------------------- + +const SBOX: [u8; 256] = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, +]; + +/// Round constants: x^(i-1) in GF(2^8) for i = 1..10. +const RCON: [u8; 10] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; + +// --------------------------------------------------------------------------- +// AES key helpers +// --------------------------------------------------------------------------- + +fn rot_word(w: u32) -> u32 { + w.rotate_left(8) +} + +fn sub_word(w: u32) -> u32 { + let b = w.to_be_bytes(); + u32::from_be_bytes([ + SBOX[b[0] as usize], + SBOX[b[1] as usize], + SBOX[b[2] as usize], + SBOX[b[3] as usize], + ]) +} + +// --------------------------------------------------------------------------- +// AES key expansion (FIPS 197, §5.2) +// --------------------------------------------------------------------------- + +fn aes128_expand_key(key: &[u8; 16]) -> Vec<[u8; 16]> { + let mut w = [0u32; 44]; + for i in 0..4 { + w[i] = u32::from_be_bytes(key[4 * i..4 * i + 4].try_into().unwrap()); + } + for i in 4..44 { + let mut temp = w[i - 1]; + if i % 4 == 0 { + temp = sub_word(rot_word(temp)) ^ ((RCON[i / 4 - 1] as u32) << 24); + } + w[i] = w[i - 4] ^ temp; + } + (0..11) + .map(|r| { + let mut rk = [0u8; 16]; + for j in 0..4 { + rk[4 * j..4 * j + 4].copy_from_slice(&w[4 * r + j].to_be_bytes()); + } + rk + }) + .collect() +} + +fn aes256_expand_key(key: &[u8; 32]) -> Vec<[u8; 16]> { + let mut w = [0u32; 60]; + for i in 0..8 { + w[i] = u32::from_be_bytes(key[4 * i..4 * i + 4].try_into().unwrap()); + } + for i in 8..60 { + let mut temp = w[i - 1]; + if i % 8 == 0 { + temp = sub_word(rot_word(temp)) ^ ((RCON[i / 8 - 1] as u32) << 24); + } else if i % 8 == 4 { + temp = sub_word(temp); + } + w[i] = w[i - 8] ^ temp; + } + (0..15) + .map(|r| { + let mut rk = [0u8; 16]; + for j in 0..4 { + rk[4 * j..4 * j + 4].copy_from_slice(&w[4 * r + j].to_be_bytes()); + } + rk + }) + .collect() +} + +// --------------------------------------------------------------------------- +// AES round operations (FIPS 197, §5.1) +// --------------------------------------------------------------------------- + +/// Multiply by x in GF(2^8) with irreducible polynomial x^8+x^4+x^3+x+1. +fn xtime(a: u8) -> u8 { + let shifted = (a as u16) << 1; + let reduce = if a & 0x80 != 0 { 0x1b } else { 0x00 }; + (shifted as u8) ^ reduce +} + +fn sub_bytes(state: &mut [u8; 16]) { + for b in state.iter_mut() { + *b = SBOX[*b as usize]; + } +} + +/// ShiftRows: cyclic left-shift of row r by r positions. +/// State layout: state[row + 4*col] (column-major, per FIPS 197). +fn shift_rows(state: &mut [u8; 16]) { + // Row 1: rotate left by 1 + let t = state[1]; + state[1] = state[5]; + state[5] = state[9]; + state[9] = state[13]; + state[13] = t; + + // Row 2: rotate left by 2 + let (t0, t1) = (state[2], state[6]); + state[2] = state[10]; + state[6] = state[14]; + state[10] = t0; + state[14] = t1; + + // Row 3: rotate left by 3 (= right by 1) + let t = state[15]; + state[15] = state[11]; + state[11] = state[7]; + state[7] = state[3]; + state[3] = t; +} + +/// MixColumns: matrix multiply each column in GF(2^8). +fn mix_columns(state: &mut [u8; 16]) { + for c in 0..4 { + let i = 4 * c; + let (s0, s1, s2, s3) = (state[i], state[i + 1], state[i + 2], state[i + 3]); + state[i] = xtime(s0) ^ (xtime(s1) ^ s1) ^ s2 ^ s3; + state[i + 1] = s0 ^ xtime(s1) ^ (xtime(s2) ^ s2) ^ s3; + state[i + 2] = s0 ^ s1 ^ xtime(s2) ^ (xtime(s3) ^ s3); + state[i + 3] = (xtime(s0) ^ s0) ^ s1 ^ s2 ^ xtime(s3); + } +} + +fn add_round_key(state: &mut [u8; 16], rk: &[u8; 16]) { + for i in 0..16 { + state[i] ^= rk[i]; + } +} + +// --------------------------------------------------------------------------- +// AES block encrypt +// --------------------------------------------------------------------------- + +fn aes_encrypt_block(block: &[u8; 16], round_keys: &[[u8; 16]]) -> [u8; 16] { + let nr = round_keys.len() - 1; // 10 for AES-128, 14 for AES-256 + let mut state = *block; + + add_round_key(&mut state, &round_keys[0]); + + for rk in round_keys.iter().take(nr).skip(1) { + sub_bytes(&mut state); + shift_rows(&mut state); + mix_columns(&mut state); + add_round_key(&mut state, rk); + } + + // Final round (no MixColumns) + sub_bytes(&mut state); + shift_rows(&mut state); + add_round_key(&mut state, &round_keys[nr]); + + state +} + +// --------------------------------------------------------------------------- +// GHASH: GF(2^128) multiplication (NIST SP 800-38D, §6.3) +// --------------------------------------------------------------------------- + +/// Constant-time multiplication in GF(2^128). +/// +/// Uses the irreducible polynomial P = x^128 + x^7 + x^2 + x + 1. +/// Bit ordering follows GCM convention: MSB of byte 0 = coefficient of x^0. +fn gf128_mul(x: &[u8; 16], y: &[u8; 16]) -> [u8; 16] { + let mut z_hi: u64 = 0; + let mut z_lo: u64 = 0; + + let mut v_hi = u64::from_be_bytes(x[0..8].try_into().unwrap()); + let mut v_lo = u64::from_be_bytes(x[8..16].try_into().unwrap()); + + for i in 0..128 { + // y_bit = Y_i (coefficient of x^i in GCM convention) + let y_bit = ((y[i / 8] >> (7 - (i % 8))) & 1) as u64; + let mask = 0u64.wrapping_sub(y_bit); + + z_hi ^= v_hi & mask; + z_lo ^= v_lo & mask; + + // Shift V right by 1 (toward higher powers); reduce if carry + let carry = v_lo & 1; + let carry_mask = 0u64.wrapping_sub(carry); + v_lo = (v_lo >> 1) | ((v_hi & 1) << 63); + v_hi = (v_hi >> 1) ^ (0xe100000000000000u64 & carry_mask); + } + + let mut result = [0u8; 16]; + result[0..8].copy_from_slice(&z_hi.to_be_bytes()); + result[8..16].copy_from_slice(&z_lo.to_be_bytes()); + result +} + +fn xor_block(a: &mut [u8; 16], b: &[u8; 16]) { + for i in 0..16 { + a[i] ^= b[i]; + } +} + +/// Feed data blocks (with zero-padding of the final partial block) into GHASH. +fn ghash_update(y: &mut [u8; 16], h: &[u8; 16], data: &[u8]) { + let mut offset = 0; + while offset + 16 <= data.len() { + let block: [u8; 16] = data[offset..offset + 16].try_into().unwrap(); + xor_block(y, &block); + *y = gf128_mul(y, h); + offset += 16; + } + if offset < data.len() { + let mut block = [0u8; 16]; + block[..data.len() - offset].copy_from_slice(&data[offset..]); + xor_block(y, &block); + *y = gf128_mul(y, h); + } +} + +/// GHASH(H, AAD, C) = process AAD ∥ C ∥ lengths through the universal hash. +fn ghash(h: &[u8; 16], aad: &[u8], ciphertext: &[u8]) -> [u8; 16] { + let mut y = [0u8; 16]; + + ghash_update(&mut y, h, aad); + ghash_update(&mut y, h, ciphertext); + + // Length block: [len(A) in bits]_64 ∥ [len(C) in bits]_64 + let mut len_block = [0u8; 16]; + len_block[0..8].copy_from_slice(&((aad.len() as u64) * 8).to_be_bytes()); + len_block[8..16].copy_from_slice(&((ciphertext.len() as u64) * 8).to_be_bytes()); + xor_block(&mut y, &len_block); + y = gf128_mul(&y, h); + + y +} + +// --------------------------------------------------------------------------- +// GCM counter +// --------------------------------------------------------------------------- + +/// Increment the rightmost 32 bits of a 128-bit counter block. +fn inc32(counter: &mut [u8; 16]) { + let c = u32::from_be_bytes(counter[12..16].try_into().unwrap()); + counter[12..16].copy_from_slice(&c.wrapping_add(1).to_be_bytes()); +} + +// --------------------------------------------------------------------------- +// Constant-time tag comparison +// --------------------------------------------------------------------------- + +fn ct_eq_16(a: &[u8; 16], b: &[u8; 16]) -> bool { + let mut diff = 0u8; + for i in 0..16 { + diff |= a[i] ^ b[i]; + } + diff == 0 +} + +// --------------------------------------------------------------------------- +// GCM core encrypt / decrypt +// --------------------------------------------------------------------------- + +fn gcm_encrypt( + round_keys: &[[u8; 16]], + nonce: &[u8; 12], + plaintext: &[u8], + aad: &[u8], +) -> (Vec, [u8; 16]) { + // H = E_K(0^128) + let h = aes_encrypt_block(&[0u8; 16], round_keys); + + // J_0 = IV ∥ 0^31 ∥ 1 + let mut j0 = [0u8; 16]; + j0[0..12].copy_from_slice(nonce); + j0[15] = 1; + + // GCTR: encrypt plaintext with counter starting at inc32(J_0) + let mut counter = j0; + inc32(&mut counter); + + let mut ciphertext = Vec::with_capacity(plaintext.len()); + let mut offset = 0; + while offset < plaintext.len() { + let keystream = aes_encrypt_block(&counter, round_keys); + let remaining = (plaintext.len() - offset).min(16); + for i in 0..remaining { + ciphertext.push(plaintext[offset + i] ^ keystream[i]); + } + offset += remaining; + inc32(&mut counter); + } + + // Tag = GHASH(H, AAD, C) ⊕ E_K(J_0) + let s = ghash(&h, aad, &ciphertext); + let ek_j0 = aes_encrypt_block(&j0, round_keys); + let mut tag = [0u8; 16]; + for i in 0..16 { + tag[i] = s[i] ^ ek_j0[i]; + } + + (ciphertext, tag) +} + +fn gcm_decrypt( + round_keys: &[[u8; 16]], + nonce: &[u8; 12], + ciphertext: &[u8], + aad: &[u8], + tag: &[u8; 16], +) -> Option> { + // H = E_K(0^128) + let h = aes_encrypt_block(&[0u8; 16], round_keys); + + // J_0 = IV ∥ 0^31 ∥ 1 + let mut j0 = [0u8; 16]; + j0[0..12].copy_from_slice(nonce); + j0[15] = 1; + + // Verify tag before decrypting + let s = ghash(&h, aad, ciphertext); + let ek_j0 = aes_encrypt_block(&j0, round_keys); + let mut expected_tag = [0u8; 16]; + for i in 0..16 { + expected_tag[i] = s[i] ^ ek_j0[i]; + } + + if !ct_eq_16(&expected_tag, tag) { + return None; + } + + // GCTR: decrypt ciphertext + let mut counter = j0; + inc32(&mut counter); + + let mut plaintext = Vec::with_capacity(ciphertext.len()); + let mut offset = 0; + while offset < ciphertext.len() { + let keystream = aes_encrypt_block(&counter, round_keys); + let remaining = (ciphertext.len() - offset).min(16); + for i in 0..remaining { + plaintext.push(ciphertext[offset + i] ^ keystream[i]); + } + offset += remaining; + inc32(&mut counter); + } + + Some(plaintext) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// AES-128-GCM encrypt. Returns `(ciphertext, 128-bit tag)`. +pub fn aes128_gcm_encrypt( + key: &[u8; 16], + nonce: &[u8; 12], + plaintext: &[u8], + aad: &[u8], +) -> (Vec, [u8; 16]) { + let rk = aes128_expand_key(key); + gcm_encrypt(&rk, nonce, plaintext, aad) +} + +/// AES-128-GCM decrypt. Returns `None` if the authentication tag is invalid. +pub fn aes128_gcm_decrypt( + key: &[u8; 16], + nonce: &[u8; 12], + ciphertext: &[u8], + aad: &[u8], + tag: &[u8; 16], +) -> Option> { + let rk = aes128_expand_key(key); + gcm_decrypt(&rk, nonce, ciphertext, aad, tag) +} + +/// AES-256-GCM encrypt. Returns `(ciphertext, 128-bit tag)`. +pub fn aes256_gcm_encrypt( + key: &[u8; 32], + nonce: &[u8; 12], + plaintext: &[u8], + aad: &[u8], +) -> (Vec, [u8; 16]) { + let rk = aes256_expand_key(key); + gcm_encrypt(&rk, nonce, plaintext, aad) +} + +/// AES-256-GCM decrypt. Returns `None` if the authentication tag is invalid. +pub fn aes256_gcm_decrypt( + key: &[u8; 32], + nonce: &[u8; 12], + ciphertext: &[u8], + aad: &[u8], + tag: &[u8; 16], +) -> Option> { + let rk = aes256_expand_key(key); + gcm_decrypt(&rk, nonce, ciphertext, aad, tag) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + fn from_hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + fn hex16(s: &str) -> [u8; 16] { + from_hex(s).try_into().unwrap() + } + + // ----------------------------------------------------------------------- + // AES block cipher (ECB) — NIST test vectors + // ----------------------------------------------------------------------- + + #[test] + fn aes128_ecb_zero_key() { + let key = [0u8; 16]; + let pt = [0u8; 16]; + let rk = aes128_expand_key(&key); + assert_eq!( + hex(&aes_encrypt_block(&pt, &rk)), + "66e94bd4ef8a2c3b884cfa59ca342b2e" + ); + } + + #[test] + fn aes128_ecb_nist() { + let key = hex16("2b7e151628aed2a6abf7158809cf4f3c"); + let pt = hex16("6bc1bee22e409f96e93d7e117393172a"); + let rk = aes128_expand_key(&key); + assert_eq!( + hex(&aes_encrypt_block(&pt, &rk)), + "3ad77bb40d7a3660a89ecaf32466ef97" + ); + } + + #[test] + fn aes256_ecb_zero_key() { + let key = [0u8; 32]; + let pt = [0u8; 16]; + let rk = aes256_expand_key(&key); + assert_eq!( + hex(&aes_encrypt_block(&pt, &rk)), + "dc95c078a2408989ad48a21492842087" + ); + } + + #[test] + fn aes256_ecb_nist() { + let key: [u8; 32] = + from_hex("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4") + .try_into() + .unwrap(); + let pt = hex16("6bc1bee22e409f96e93d7e117393172a"); + let rk = aes256_expand_key(&key); + assert_eq!( + hex(&aes_encrypt_block(&pt, &rk)), + "f3eed1bdb5d2a03c064b5a7e3db181f8" + ); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 1: AES-128, empty plaintext, empty AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_128_case1_encrypt() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let (ct, tag) = aes128_gcm_encrypt(&key, &nonce, &[], &[]); + assert!(ct.is_empty()); + assert_eq!(hex(&tag), "58e2fccefa7e3061367f1d57a4e7455a"); + } + + #[test] + fn gcm_128_case1_decrypt() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let tag = hex16("58e2fccefa7e3061367f1d57a4e7455a"); + let pt = aes128_gcm_decrypt(&key, &nonce, &[], &[], &tag).unwrap(); + assert!(pt.is_empty()); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 2: AES-128, 16-byte plaintext, empty AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_128_case2_encrypt() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let pt = [0u8; 16]; + let (ct, tag) = aes128_gcm_encrypt(&key, &nonce, &pt, &[]); + assert_eq!(hex(&ct), "0388dace60b6a392f328c2b971b2fe78"); + assert_eq!(hex(&tag), "ab6e47d42cec13bdf53a67b21257bddf"); + } + + #[test] + fn gcm_128_case2_decrypt() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let ct = from_hex("0388dace60b6a392f328c2b971b2fe78"); + let tag = hex16("ab6e47d42cec13bdf53a67b21257bddf"); + let pt = aes128_gcm_decrypt(&key, &nonce, &ct, &[], &tag).unwrap(); + assert_eq!(pt, vec![0u8; 16]); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 3: AES-128, 64-byte plaintext, empty AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_128_case3_encrypt() { + let key = hex16("feffe9928665731c6d6a8f9467308308"); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let pt = from_hex( + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b391aafd255", + ); + let (ct, tag) = aes128_gcm_encrypt(&key, &nonce, &pt, &[]); + assert_eq!( + hex(&ct), + "42831ec2217774244b7221b784d0d49c\ + e3aa212f2c02a4e035c17e2329aca12e\ + 21d514b25466931c7d8f6a5aac84aa05\ + 1ba30b396a0aac973d58e091473f5985" + ); + assert_eq!(hex(&tag), "4d5c2af327cd64a62cf35abd2ba6fab4"); + } + + #[test] + fn gcm_128_case3_decrypt() { + let key = hex16("feffe9928665731c6d6a8f9467308308"); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let ct = from_hex( + "42831ec2217774244b7221b784d0d49c\ + e3aa212f2c02a4e035c17e2329aca12e\ + 21d514b25466931c7d8f6a5aac84aa05\ + 1ba30b396a0aac973d58e091473f5985", + ); + let tag = hex16("4d5c2af327cd64a62cf35abd2ba6fab4"); + let pt = aes128_gcm_decrypt(&key, &nonce, &ct, &[], &tag).unwrap(); + assert_eq!( + hex(&pt), + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b391aafd255" + ); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 4: AES-128, 60-byte plaintext, 20-byte AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_128_case4_encrypt() { + let key = hex16("feffe9928665731c6d6a8f9467308308"); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let pt = from_hex( + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b39", + ); + let aad = from_hex("feedfacedeadbeeffeedfacedeadbeefabaddad2"); + let (ct, tag) = aes128_gcm_encrypt(&key, &nonce, &pt, &aad); + assert_eq!( + hex(&ct), + "42831ec2217774244b7221b784d0d49c\ + e3aa212f2c02a4e035c17e2329aca12e\ + 21d514b25466931c7d8f6a5aac84aa05\ + 1ba30b396a0aac973d58e091", + ); + assert_eq!(hex(&tag), "5bc94fbc3221a5db94fae95ae7121a47"); + } + + #[test] + fn gcm_128_case4_decrypt() { + let key = hex16("feffe9928665731c6d6a8f9467308308"); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let ct = from_hex( + "42831ec2217774244b7221b784d0d49c\ + e3aa212f2c02a4e035c17e2329aca12e\ + 21d514b25466931c7d8f6a5aac84aa05\ + 1ba30b396a0aac973d58e091", + ); + let aad = from_hex("feedfacedeadbeeffeedfacedeadbeefabaddad2"); + let tag = hex16("5bc94fbc3221a5db94fae95ae7121a47"); + let pt = aes128_gcm_decrypt(&key, &nonce, &ct, &aad, &tag).unwrap(); + assert_eq!( + hex(&pt), + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b39" + ); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 13: AES-256, empty plaintext, empty AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_256_case13_encrypt() { + let key = [0u8; 32]; + let nonce = [0u8; 12]; + let (ct, tag) = aes256_gcm_encrypt(&key, &nonce, &[], &[]); + assert!(ct.is_empty()); + assert_eq!(hex(&tag), "530f8afbc74536b9a963b4f1c4cb738b"); + } + + #[test] + fn gcm_256_case13_decrypt() { + let key = [0u8; 32]; + let nonce = [0u8; 12]; + let tag = hex16("530f8afbc74536b9a963b4f1c4cb738b"); + let pt = aes256_gcm_decrypt(&key, &nonce, &[], &[], &tag).unwrap(); + assert!(pt.is_empty()); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 14: AES-256, 16-byte plaintext, empty AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_256_case14_encrypt() { + let key = [0u8; 32]; + let nonce = [0u8; 12]; + let pt = [0u8; 16]; + let (ct, tag) = aes256_gcm_encrypt(&key, &nonce, &pt, &[]); + assert_eq!(hex(&ct), "cea7403d4d606b6e074ec5d3baf39d18"); + assert_eq!(hex(&tag), "d0d1c8a799996bf0265b98b5d48ab919"); + } + + #[test] + fn gcm_256_case14_decrypt() { + let key = [0u8; 32]; + let nonce = [0u8; 12]; + let ct = from_hex("cea7403d4d606b6e074ec5d3baf39d18"); + let tag = hex16("d0d1c8a799996bf0265b98b5d48ab919"); + let pt = aes256_gcm_decrypt(&key, &nonce, &ct, &[], &tag).unwrap(); + assert_eq!(pt, vec![0u8; 16]); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 15: AES-256, 64-byte plaintext, empty AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_256_case15_encrypt() { + let key: [u8; 32] = + from_hex("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") + .try_into() + .unwrap(); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let pt = from_hex( + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b391aafd255", + ); + let (ct, tag) = aes256_gcm_encrypt(&key, &nonce, &pt, &[]); + assert_eq!( + hex(&ct), + "522dc1f099567d07f47f37a32a84427d\ + 643a8cdcbfe5c0c97598a2bd2555d1aa\ + 8cb08e48590dbb3da7b08b1056828838\ + c5f61e6393ba7a0abcc9f662898015ad" + ); + assert_eq!(hex(&tag), "b094dac5d93471bdec1a502270e3cc6c"); + } + + #[test] + fn gcm_256_case15_decrypt() { + let key: [u8; 32] = + from_hex("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") + .try_into() + .unwrap(); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let ct = from_hex( + "522dc1f099567d07f47f37a32a84427d\ + 643a8cdcbfe5c0c97598a2bd2555d1aa\ + 8cb08e48590dbb3da7b08b1056828838\ + c5f61e6393ba7a0abcc9f662898015ad", + ); + let tag = hex16("b094dac5d93471bdec1a502270e3cc6c"); + let pt = aes256_gcm_decrypt(&key, &nonce, &ct, &[], &tag).unwrap(); + assert_eq!( + hex(&pt), + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b391aafd255" + ); + } + + // ----------------------------------------------------------------------- + // GCM Test Case 16: AES-256, 60-byte plaintext, 20-byte AAD + // ----------------------------------------------------------------------- + + #[test] + fn gcm_256_case16_encrypt() { + let key: [u8; 32] = + from_hex("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") + .try_into() + .unwrap(); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let pt = from_hex( + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b39", + ); + let aad = from_hex("feedfacedeadbeeffeedfacedeadbeefabaddad2"); + let (ct, tag) = aes256_gcm_encrypt(&key, &nonce, &pt, &aad); + assert_eq!( + hex(&ct), + "522dc1f099567d07f47f37a32a84427d\ + 643a8cdcbfe5c0c97598a2bd2555d1aa\ + 8cb08e48590dbb3da7b08b1056828838\ + c5f61e6393ba7a0abcc9f662", + ); + assert_eq!(hex(&tag), "76fc6ece0f4e1768cddf8853bb2d551b"); + } + + #[test] + fn gcm_256_case16_decrypt() { + let key: [u8; 32] = + from_hex("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") + .try_into() + .unwrap(); + let nonce: [u8; 12] = from_hex("cafebabefacedbaddecaf888").try_into().unwrap(); + let ct = from_hex( + "522dc1f099567d07f47f37a32a84427d\ + 643a8cdcbfe5c0c97598a2bd2555d1aa\ + 8cb08e48590dbb3da7b08b1056828838\ + c5f61e6393ba7a0abcc9f662", + ); + let aad = from_hex("feedfacedeadbeeffeedfacedeadbeefabaddad2"); + let tag = hex16("76fc6ece0f4e1768cddf8853bb2d551b"); + let pt = aes256_gcm_decrypt(&key, &nonce, &ct, &aad, &tag).unwrap(); + assert_eq!( + hex(&pt), + "d9313225f88406e5a55909c5aff5269a\ + 86a7a9531534f7da2e4c303d8a318a72\ + 1c3c0c95956809532fcf0e2449a6b525\ + b16aedf5aa0de657ba637b39" + ); + } + + // ----------------------------------------------------------------------- + // Tag tamper detection + // ----------------------------------------------------------------------- + + #[test] + fn decrypt_rejects_tampered_tag() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let pt = b"hello world"; + let (ct, mut tag) = aes128_gcm_encrypt(&key, &nonce, pt, &[]); + tag[0] ^= 1; + assert!(aes128_gcm_decrypt(&key, &nonce, &ct, &[], &tag).is_none()); + } + + #[test] + fn decrypt_rejects_tampered_ciphertext() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let pt = b"hello world"; + let (mut ct, tag) = aes128_gcm_encrypt(&key, &nonce, pt, &[]); + ct[0] ^= 1; + assert!(aes128_gcm_decrypt(&key, &nonce, &ct, &[], &tag).is_none()); + } + + #[test] + fn decrypt_rejects_tampered_aad() { + let key = [0u8; 16]; + let nonce = [0u8; 12]; + let pt = b"hello world"; + let aad = b"associated data"; + let (ct, tag) = aes128_gcm_encrypt(&key, &nonce, pt, aad); + let bad_aad = b"Associated data"; + assert!(aes128_gcm_decrypt(&key, &nonce, &ct, bad_aad, &tag).is_none()); + } + + // ----------------------------------------------------------------------- + // Round-trip + // ----------------------------------------------------------------------- + + #[test] + fn aes128_gcm_roundtrip() { + let key = hex16("deadbeefdeadbeefdeadbeefdeadbeef"); + let nonce: [u8; 12] = from_hex("0102030405060708090a0b0c").try_into().unwrap(); + let pt = b"The quick brown fox jumps over the lazy dog"; + let aad = b"additional authenticated data"; + let (ct, tag) = aes128_gcm_encrypt(&key, &nonce, pt, aad); + let recovered = aes128_gcm_decrypt(&key, &nonce, &ct, aad, &tag).unwrap(); + assert_eq!(recovered, pt); + } + + #[test] + fn aes256_gcm_roundtrip() { + let key: [u8; 32] = + from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + .try_into() + .unwrap(); + let nonce: [u8; 12] = from_hex("0102030405060708090a0b0c").try_into().unwrap(); + let pt = b"The quick brown fox jumps over the lazy dog"; + let aad = b"additional authenticated data"; + let (ct, tag) = aes256_gcm_encrypt(&key, &nonce, pt, aad); + let recovered = aes256_gcm_decrypt(&key, &nonce, &ct, aad, &tag).unwrap(); + assert_eq!(recovered, pt); + } +} diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs index e71cf2c..dc1b7a0 100644 --- a/crates/crypto/src/lib.rs +++ b/crates/crypto/src/lib.rs @@ -1,5 +1,6 @@ //! Pure Rust cryptography — AES-GCM, ChaCha20-Poly1305, SHA-2, X25519, RSA, X.509, ASN.1. +pub mod aes_gcm; pub mod hkdf; pub mod hmac; pub mod sha2; -- 2.51.2