Compare commits

...
5 Commits
Author SHA1 Message Date
Felix Klement ab6495ad10 asdf 2026-10-07 22:39:28 +02:00
Felix Klement 3cb5952e6d asdf 2026-10-07 21:20:56 +02:00
Felix Klement a4c2ff3b32 asdf 2026-10-06 14:14:14 +02:00
Felix Klement 656f45c5d2 asdf 2026-10-06 11:36:38 +02:00
Felix Klement 76cedf8400 asdf 2026-10-06 11:33:00 +02:00
16 changed files with 165 additions and 147 deletions

No files matched your search

+14 -7
View File
@@ -74,7 +74,7 @@ Environment; see *Contact form* below.
and no third party is involved, so there is nothing to consent to. The form therefore needs
JavaScript; without it a `<noscript>` line points to the email address.
In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `/notify/verify` to the handler on `127.0.0.1:3000`; both are started
In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `GET /notify/verify` to the handler on `127.0.0.1:3000`; both are started
by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down,
visitors are sent to the error page. Without SMTP settings the handler does not start and the
rest of the site is served as usual.
@@ -98,11 +98,11 @@ Environment; see *Contact form* below.
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
`list=cipherverifier-alpha`, the honeypot `website` and the proof-of-work answer `altcha` to
`POST /notify`, handled by `server/contact.mjs` like the contact form. It is double opt-in
without a database: the handler emails the address a link to `/notify/confirm` carrying a
signed token (address, language, time of consent; valid 7 days) and keeps nothing. That page
posts the token to `POST /notify/verify` with a button (mail scanners open links, they do not
press buttons), and the handler sends the confirmed registration, with the consent wording and
both times, to `CONTACT_TO`. That mailbox is the list. The privacy page promises deletion
without a database: the handler emails the address a link to `GET /notify/verify` carrying a
signed token (address, language, time of consent; valid 7 days) and keeps nothing. Opening the
link confirms: the handler sends the confirmed registration, with the consent wording and both
times, to `CONTACT_TO`, and redirects to `/notify/confirmed`. A mail scanner that opens every
link in a message will confirm it too. Old `/notify/confirm` links are redirected by nginx. That mailbox is the list. The privacy page promises deletion
after the alpha mail is sent or on withdrawal. The result pages `/notify/confirmed` and
`/notify/error` are `noindex` and left out of the sitemap. Set the flag to false to bring back
the full product page.
@@ -190,7 +190,14 @@ photography existed:
| `engineer-glass.jpg` | Method section | https://www.magnific.com/app/creation/3zdScUIREY |
| `sector-5g.jpg` | Sectors, tab 1 | https://www.magnific.com/app/creation/s7ZQP52l8e |
| `sector-substation.jpg` | Sectors, tab 2 | https://www.magnific.com/app/creation/yi0r27vPW9 |
| `sector-datacentre.jpg` | Sectors, tab 3 | https://www.magnific.com/app/creation/eIHAvetdqL |
| `sector-datacentre.jpg` | Sectors, tab 5 | https://www.magnific.com/app/creation/eIHAvetdqL |
`sector-ot.jpg` (Sectors, tab 3, operational technology) is a real photograph from the Freepik
stock catalogue via Magnific, "Workers in control room" (free licence, Canon EOS 5D Mark III, 2017),
downloaded on 7 October 2026 and scaled to 2304 px.
`sector-rd-engineer.jpg` (Sectors, tab 4, products with digital elements) comes from the same
catalogue: "Male Industrial Engineer Solving Problems Working on a Personal Computer" (premium licence), downloaded on 7 October 2026 and
scaled to 2304 px.
They are placeholders in the sense of the brief: swap them for the company's own documentary
photography when it exists, keeping the same file names and the `img-doc` treatment. The grid
+14 -2
View File
@@ -51,8 +51,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 10s;
}
# The alpha sign-up, same handler: the form, and the confirmation from the link in its email.
location ~ ^/notify(/verify)?$ {
# The alpha sign-up, same handler: the form posts here.
location = /notify {
limit_except POST { deny all; }
client_max_body_size 64k;
proxy_pass http://127.0.0.1:3000;
@@ -63,6 +63,18 @@ server {
proxy_intercept_errors on;
error_page 502 503 504 = @notify_down;
}
# The link in the confirmation email; opening it confirms.
location = /notify/verify {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
proxy_intercept_errors on;
error_page 502 503 504 = @notify_down;
}
# Links sent before the confirmation moved off the button page.
location ~ ^(/de)?/notify/confirm$ { return 302 /notify/verify$is_args$args; }
location @notify_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/notify/error; }
return 303 /notify/error;
+15 -12
View File
@@ -9,12 +9,11 @@
* A post without a solved challenge is refused, so the form needs scripting; the address next
* to it does not.
*
* Alpha sign-up (POST /notify, POST /notify/verify). Double opt-in without a database: the form
* Alpha sign-up (POST /notify, GET /notify/verify). Double opt-in without a database: the form
* posts email, consent=yes, list, lang, website and altcha; the handler emails the address a link
* to /notify/confirm carrying a signed token (address, language, time of consent, expiry). That
* page posts the token back to /notify/verify, which checks the signature and sends the confirmed
* registration to CONTACT_TO. The office mailbox is the list. The confirmation is a button, not
* the link itself, because mail scanners open every link in a message.
* to /notify/verify carrying a signed token (address, language, time of consent, expiry). Opening
* the link confirms: the handler checks the signature, sends the confirmed registration to
* CONTACT_TO and redirects to /notify/confirmed. The office mailbox is the list.
*
* Configuration comes from the environment only, see README.md.
*/
@@ -268,7 +267,7 @@ async function notify(req, res, form, lang) {
const now = Date.now();
const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS });
const link = `${SITE}${lang === 'de' ? '/de' : ''}/notify/confirm?t=${token}`;
const link = `${SITE}/notify/verify?t=${token}`;
try {
await transport.sendMail({
from: { name: 'CipherCell', address: FROM },
@@ -282,10 +281,10 @@ async function notify(req, res, form, lang) {
}
}
/** Step two: the link's token comes back; the confirmed registration goes to the office. */
async function verify(req, res, form) {
const t = unsign(form.get('t'));
const lang = t?.lang === 'de' ? 'de' : form.get('lang') === 'de' ? 'de' : 'en';
/** Step two: the link is opened; the confirmed registration goes to the office. */
async function verify(req, res, query) {
const t = unsign(query.get('t'));
const lang = t?.lang === 'de' ? 'de' : 'en';
const now = Date.now();
if (!t || typeof t.email !== 'string' || !(t.expires > now) || !(t.consented > 0)) {
redirect(res, lang, '/notify/error');
@@ -324,16 +323,20 @@ async function verify(req, res, form) {
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const clientIp = (req) => String(req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown');
const ROUTES = { '/contact': contact, '/notify': notify, '/notify/verify': verify };
const ROUTES = { '/contact': contact, '/notify': notify };
const server = createServer(async (req, res) => {
const path = (req.url || '').split('?')[0];
const [path, search = ''] = (req.url || '').split('?');
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (path === '/contact/challenge' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await issueChallenge()));
return;
}
if (path === '/notify/verify' && req.method === 'GET') {
await verify(req, res, new URLSearchParams(search));
return;
}
const route = ROUTES[path];
if (req.method !== 'POST' || !route) { res.writeHead(404).end(); return; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

-1
View File
@@ -7,7 +7,6 @@ const { lang, tone = 'paper' } = Astro.props;
const ui = t(lang);
const home = localePath(lang, '/');
const items = [
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
{ href: localePath(lang, '/cipherverifier'), label: ui.nav.product },
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
{ href: localePath(lang, '/team'), label: ui.nav.team },
+2
View File
@@ -93,6 +93,8 @@ const { for: formId, labels } = Astro.props;
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* With the head alone (while sending), it sits centred in the panel. */
.toast:has(.toast__message:empty) { padding-bottom: var(--space-4); }
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
to read. Pointer or focus pauses it. */
.toast__timer {
@@ -11,28 +11,18 @@ const ui = t(lang);
/* The partner logos as on ciphercell.de (see PartnerLogo.astro for the two states). `h` balances
optical size: the incubator wordmark is long and thin, the other two carry a mark. */
const roles = ui.proof.partnerRoles;
const roles = ui.partners.roles;
const logos = await Promise.all([
{ name: 'xG-Incubator', role: roles.incubator, src: incubator, h: 'var(--space-5)' },
{ name: 'xG-ALOE', role: roles.aloe, src: aloe, h: 'var(--space-7)' },
{ name: lang === 'de' ? 'Universität Passau' : 'University of Passau', role: roles.passau, src: passau, h: 'var(--space-7)' },
].map(async (l) => ({ ...l, url: (await getImage({ src: l.src, format: 'webp' })).src })));
---
<section class="section proof" aria-labelledby="proof-title">
<section class="section section--tight partners" aria-labelledby="partners-title">
<div class="container grid">
<h2 id="proof-title" class="h2 proof__title">{ui.proof.title}</h2>
<ul class="proof__list" role="list">
{ui.proof.items.map((p) => (
<li class="proof__item">
<p class="proof__name">{p.name}</p>
<p class="muted proof__body">{p.body}</p>
</li>
))}
</ul>
<div class="partners">
<p class="small muted" id="partners-label">{ui.proof.partners}</p>
<ul class="partners__list" role="list" aria-labelledby="partners-label">
<div class="partners__block">
<h2 class="small muted partners__title" id="partners-title">{ui.partners.title}</h2>
<ul class="partners__list" role="list">
{logos.map((l) => (
<li class="partners__item" data-partner>
<PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} />
@@ -48,18 +38,8 @@ const logos = await Promise.all([
</section>
<style>
.proof { border-top: var(--hairline) solid var(--line); }
.proof__title { grid-column: 1 / -1; }
.proof__list { grid-column: 1 / -1; }
.proof__item {
display: grid;
gap: var(--space-2);
padding-block: var(--space-5);
border-top: var(--hairline) solid var(--line);
}
.proof__item:last-child { border-bottom: var(--hairline) solid var(--line); }
.proof__name { font-weight: var(--weight-medium); }
.proof__body { max-width: 30em; }
.partners__title { font-weight: var(--weight-regular); }
/* Hover answers the pointer with the logo's own ground: night, original colours. */
[data-partner] { transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
@@ -68,9 +48,8 @@ const logos = await Promise.all([
}
@media (prefers-reduced-motion: reduce) { [data-partner] { transition: none; } }
.partners {
.partners__block {
grid-column: 1 / -1;
margin-top: var(--space-9);
display: grid;
gap: var(--space-5);
}
@@ -115,9 +94,6 @@ const logos = await Promise.all([
}
@media (min-width: 48rem) {
.proof__title { grid-column: 1 / span 6; }
.proof__list { grid-column: 7 / span 6; }
.proof__item { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); column-gap: var(--gap); }
.partners__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.partners__item + .partners__item { border-top: 0; border-left: var(--hairline) solid var(--line); }
}
+2 -4
View File
@@ -223,7 +223,7 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
<script>
/**
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
* the pointer or focus is inside or the section is off screen; picking a statement stops it.
* keyboard focus is inside or the section is off screen; picking a statement stops it.
*/
const root = document.querySelector<HTMLElement>('[data-quotes]');
if (root) {
@@ -259,10 +259,8 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
picks.forEach((pick, i) => pick.addEventListener('click', () => { setPlaying(false); show(i); }));
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
root.addEventListener('pointerenter', () => hold(true));
root.addEventListener('pointerleave', () => hold(root.contains(document.activeElement)));
root.addEventListener('focusin', () => hold(true));
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(root.matches(':hover')); });
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
// Off screen, the clock stops, so a reader arriving from above starts on a fresh statement.
new IntersectionObserver(([entry]) => root.toggleAttribute('data-away', !entry.isIntersecting), { threshold: 0.4 }).observe(root);
+76 -13
View File
@@ -2,13 +2,17 @@
import { Picture } from 'astro:assets';
import fiveG from '../../assets/images/sector-5g.jpg';
import substation from '../../assets/images/sector-substation.jpg';
import controlRoom from '../../assets/images/sector-ot.jpg';
import products from '../../assets/images/sector-rd-engineer.jpg';
import datacentre from '../../assets/images/sector-datacentre.jpg';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const images = [fiveG, substation, datacentre];
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id: `sector-${i + 1}` }));
const images = [fiveG, substation, controlRoom, products, datacentre];
/* Where each picture keeps its subject when cropped; the R&D engineer sits on the left. */
const focus = ['center', 'center', 'center', '30% center', 'center'];
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], focus: focus[i], id: `sector-${i + 1}` }));
---
<section class="section sectors" id="sectors" aria-labelledby="sectors-title">
<div class="container sectors__grid" data-sectors>
@@ -36,11 +40,12 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
alt={item.imageAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[480, 768, 1024, 1440]}
sizes="(min-width: 64rem) 50vw, 100vw"
widths={[480, 768, 1024, 1440, 1920]}
sizes="(min-width: 64rem) 60vw, 100vw"
loading="lazy"
decoding="async"
class="sectors__img img-doc"
style={`object-position: ${item.focus}`}
pictureAttributes={{ class: 'sectors__picture' }}
/>
</div>
@@ -56,11 +61,12 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
alt=""
formats={['avif']}
fallbackFormat="webp"
widths={[480, 768, 1024, 1440]}
sizes="(min-width: 64rem) 50vw, 100vw"
widths={[480, 768, 1024, 1440, 1920]}
sizes="(min-width: 64rem) 60vw, 100vw"
loading="lazy"
decoding="async"
class="sectors__img img-doc"
style={`object-position: ${item.focus}`}
pictureAttributes={{ class: 'sectors__picture sectors__picture--stage', 'data-stage-item': '', 'data-active': i === 0 ? '' : undefined }}
/>
))}
@@ -69,6 +75,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
</section>
<style>
.sectors { border-top: var(--hairline) solid var(--line); }
.sectors__grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
@@ -81,11 +88,34 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
flex-direction: column;
gap: var(--space-8);
}
.sectors__tabs { margin-top: auto; }
.sectors__tab + .sectors__tab { margin-top: var(--space-5); }
/* The tabs are rows between hairlines. The selected row's rule turns ink: that is the marker. */
.sectors__tabs {
margin-top: auto;
border-bottom: var(--hairline) solid var(--line);
}
.sectors__tab {
position: relative;
border-top: var(--hairline) solid var(--line);
}
.sectors__tab::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: calc(-1 * var(--hairline));
height: var(--hairline);
background: var(--ink);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur-slow) var(--ease);
}
.sectors__tab:has([aria-selected='true'])::before { transform: none; }
.sectors__heading { font-size: inherit; font-weight: inherit; }
/* The whole row is the button, so the target is as wide as the column. */
.sectors__button {
display: block;
width: 100%;
padding-block: var(--space-5) var(--space-3);
text-align: left;
font-size: var(--step-h3);
line-height: var(--leading-h3);
@@ -96,7 +126,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
}
.sectors__button[aria-selected='false'] { color: var(--ink-muted); }
.sectors__button[aria-selected='false']:hover { color: var(--ink); }
.sectors__panel-inner { display: grid; gap: var(--space-5); padding-top: var(--space-3); }
.sectors__panel-inner { display: grid; gap: var(--space-5); }
/* The button keeps a small step below its title, so an open body sits close under it and the
focus ring never crosses the text. The rest of the row padding is the panel's margin: below
the body when open, in place of it when closed, so every row is symmetric. */
.sectors__panel { margin-bottom: var(--space-5); }
.sectors__panel p { max-width: 28em; }
:global(.sectors__picture) { display: block; width: 100%; }
.sectors__img, :global(.sectors__img) { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; }
@@ -110,19 +144,44 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows var(--dur-slow) var(--ease);
transition:
grid-template-rows var(--dur-slow) var(--ease),
margin var(--dur-slow) var(--ease);
}
:global(html.js) .sectors__panel-inner { min-height: 0; }
:global(html.js) .sectors__panel[data-open] { grid-template-rows: 1fr; }
/* A closed panel is hidden, not only collapsed, so it does not take focus while invisible.
Visibility switches at the end of the collapse and at the start of the expand. */
:global(html.js) .sectors__panel:not([data-open]) {
margin-bottom: calc(var(--space-5) - var(--space-3));
visibility: hidden;
transition:
grid-template-rows var(--dur-slow) var(--ease),
margin var(--dur-slow) var(--ease),
visibility 0s linear var(--dur-slow);
}
@media (min-width: 64rem) {
.sectors__text { grid-column: 1 / span 5; }
/* The image runs past the container to the right edge of the window: the page gutter, plus
half of whatever the window is wider than the container. The section clips, so a classic
scrollbar (counted in 100vw) cannot cause a horizontal scroll. */
.sectors { overflow-x: clip; }
/* The image box is at least 5:4 (never taller than 45rem), and stretches with the row when
the list beside it is taller, so its top and bottom always meet the heading and the last rule. */
.sectors__media {
grid-column: 7 / span 6;
display: grid;
display: block;
position: relative;
margin-right: calc(-1 * (max(var(--gutter), var(--safe-right)) + max(0px, (100vw - var(--container)) / 2)));
}
/* The minimum: a spacer as tall as 5:4 of the width, capped at 45rem. A grid item stretches
to its row, so a taller list beside it makes the box taller; the pictures fill the box. */
.sectors__media::before { content: ''; display: block; padding-top: min(80%, 45rem); }
.sectors__media :global(.sectors__img) { height: 100%; aspect-ratio: auto; }
.sectors__media :global(.sectors__picture--stage) {
grid-area: 1 / 1;
position: absolute;
inset: 0;
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
}
@@ -132,7 +191,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
}
@media (prefers-reduced-motion: reduce) {
.sectors__panel, .sectors__media :global(.sectors__picture--stage) { transition: none; }
.sectors__panel,
:global(html.js) .sectors__panel,
:global(html.js) .sectors__panel:not([data-open]),
.sectors__tab::before,
.sectors__media :global(.sectors__picture--stage) { transition: none; }
}
</style>
+2 -2
View File
@@ -8,7 +8,7 @@ import philipp from '../assets/images/people/philipp.png';
import karin from '../assets/images/people/karin.png';
import valentin from '../assets/images/people/valentin.png';
export type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM';
export type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM' | 'networkengineer';
export interface Person {
name: string;
@@ -20,7 +20,7 @@ export const team: Person[] = [
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix },
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian },
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander },
{ name: 'Erik Foris', role: 'engineer', portrait: erik },
{ name: 'Erik Foris', role: 'networkengineer', portrait: erik },
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc },
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp },
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin },
+28 -46
View File
@@ -39,7 +39,6 @@ const en = {
},
nav: {
skip: 'Skip to content',
capabilities: 'Capabilities',
product: 'Product',
sectors: 'Sectors',
insights: 'Insights',
@@ -54,8 +53,8 @@ const en = {
},
manifesto: {
lines: ['Security built in,', 'not bolted on.'],
p1: 'Infrastructure is software now. Private 5G networks, industrial control systems and cloud platforms are assembled from the code and components of many vendors. Every interface between them is an attack surface, and off-the-shelf tools were not built for that.',
p2: 'CipherCell is a security engineering company for software. We model real adversaries, build zero trust into the architecture rather than around it, and prove that the code holds. From the first threat model to the running system.',
p1: 'Infrastructure is software now. Private 5G networks, industrial control systems, digital products and cloud platforms are assembled from the code and components of many vendors, and every interface between them is an attack surface that off-the-shelf tools were never designed to cover.',
p2: 'CipherCell is a security engineering company built by specialists in networks and software. We take on the hard problems, whatever the vertical, and solve them with security at the core of every decision.',
cta: 'About CipherCell',
},
method: {
@@ -82,22 +81,18 @@ const en = {
],
},
sectors: {
title: 'Who we work with',
title: 'Who we work with.',
items: [
{ title: 'Private 5G and campus networks', body: 'Software-defined networks with open interfaces and a multi-vendor supply chain. Our origin, and still the hardest case.', imageAlt: 'A private 5G antenna and radio cabinet on the parapet of a factory roof, cranes in the distance.' },
{ title: 'Critical infrastructure', body: 'Control and network software where a breach stops production, damages equipment or endangers people.', imageAlt: 'An electrical substation seen straight from above: transformers, switchgear and a service road.' },
{ title: 'Operational technology (OT)', body: 'Controllers, SCADA and the engineering software that configures them. Systems built to run for decades, often on protocols without authentication, and rarely patched.', imageAlt: 'Three operators in hard hats at their consoles in a plant control room, facing a wall-sized diagram of the process.' },
{ title: 'Products with digital elements', body: 'Devices, firmware and installable software sold in the EU. Under the Cyber Resilience Act, the manufacturer answers for their security for the whole support period.', imageAlt: 'An engineer at two monitors in a glass-walled development lab, engineering software on screen and a machined test part on the desk.' },
{ title: 'Cloud-native systems', body: 'Containers, service meshes and CI/CD pipelines that carry infrastructure workloads. Security that runs as code, alongside yours.', imageAlt: 'A cold aisle between server racks in a data centre, a technician far in the distance.' },
],
},
proof: {
title: 'Where we come from.',
items: [
{ name: 'University of Passau', body: 'Our method grew out of academic security research. The university remains our research partner.' },
{ name: 'xG-ALOE alliance and xG-Incubator', body: 'Partners in the alliance for open enterprise networks, from lab to deployment.' },
{ name: 'Federal Ministry of Research, Technology and Space', body: 'Our work is publicly funded.' },
],
partners: 'Trusted by and working with',
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
partners: {
title: 'Trusted by and working with',
roles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
},
quote: {
label: 'Statements from the founders',
@@ -253,13 +248,13 @@ const en = {
soon: {
eyebrow: 'Product',
title: ['CipherVerifier.', 'Coming soon.'],
lead: 'A self-hosted verification pipeline for software. Currently in a closed demonstrator phase.',
lead: 'A self-hosted security pipeline for software. Currently in a closed demonstrator phase.',
status: {
label: 'System status',
rows: [
{ key: 'Phase', value: 'Closed demonstrator', live: true },
{ key: 'Next', value: 'Alpha release' },
{ key: 'Deployment', value: 'Self-hosted' },
{ key: 'Focus', value: 'Software security' },
],
},
notify: {
@@ -278,11 +273,6 @@ const en = {
failed: 'Registration did not go through. Please try again, or write to office@ciphercell.de.',
},
pages: {
confirm: {
title: 'Confirm your registration.',
body: 'One click, and your address is registered for the CipherVerifier alpha. One email when it is released. Nothing else.',
submit: 'Confirm registration',
},
confirmed: {
title: 'Registered.',
body: 'Your address is confirmed. You will hear from us once, when the CipherVerifier alpha is released.',
@@ -337,10 +327,11 @@ const en = {
roles: {
ceo: 'Founder and CEO',
cto: 'Founder and CTO',
engineer: 'Software engineer',
bizdev: 'Business development',
studentF: 'Working student, development',
studentM: 'Working student, development',
engineer: 'Software Engineer',
networkengineer: 'Network & Infrastructure Engineer',
bizdev: 'Business Development',
studentF: 'Mathematical Engineer',
studentM: 'Process Optimization & Automation',
},
research: {
title: ['Published', 'research.'],
@@ -389,7 +380,6 @@ const de: typeof en = {
},
nav: {
skip: 'Zum Inhalt springen',
capabilities: 'Leistungen',
product: 'Produkt',
sectors: 'Einsatzfelder',
insights: 'Einblicke',
@@ -404,8 +394,8 @@ const de: typeof en = {
},
manifesto: {
lines: ['Sicherheit eingebaut,', 'nicht nachträglich angefügt.'],
p1: 'Infrastruktur ist heute Software. Private 5G-Netze, industrielle Steuerungssysteme und Cloud-Plattformen werden aus Code und Komponenten vieler Hersteller zusammengesetzt. Jede Schnittstelle dazwischen ist eine Angriffsfläche, und Standardwerkzeuge sind dafür nicht gebaut.',
p2: 'CipherCell ist ein Security-Engineering-Unternehmen für Software. Wir modellieren reale Angreifer, verankern Zero Trust in der Architektur statt daneben und weisen nach, dass der Code standhält. Vom ersten Bedrohungsmodell bis zum laufenden System.',
p1: 'Infrastruktur ist heute Software. Private 5G-Netze, industrielle Steuerungssysteme, digitale Produkte und Cloud-Plattformen setzen sich aus Code und Komponenten vieler Hersteller zusammen und jede Schnittstelle dazwischen ist eine Angriffsfläche, für die Lösungen von der Stange nie gedacht waren.',
p2: 'CipherCell ist ein Security-Engineering-Unternehmen von Spezialisten für Netzwerke und Software. Wir packen die schwierigen Probleme an, ganz gleich in welcher Branche, und machen Sicherheit zum Kern jeder Entscheidung.',
cta: 'Über CipherCell',
},
method: {
@@ -432,22 +422,18 @@ const de: typeof en = {
],
},
sectors: {
title: 'Für wen wir arbeiten',
title: 'Für wen wir arbeiten.',
items: [
{ title: 'Private 5G- und Campusnetze', body: 'Softwaredefinierte Netze mit offenen Schnittstellen und einer Lieferkette aus vielen Herstellern. Unser Ursprung, und noch immer der schwerste Fall.', imageAlt: 'Eine private 5G-Antenne mit Funkschrank auf der Attika eines Fabrikdachs, Kräne im Hintergrund.' },
{ title: 'Kritische Infrastruktur', body: 'Steuerungs- und Netzsoftware, bei der ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' },
{ title: 'Operational Technology (OT)', body: 'Steuerungen, SCADA und die Engineering-Software, mit der sie konfiguriert werden. Anlagen, die Jahrzehnte laufen, oft über Protokolle ohne Authentisierung, und selten Updates erhalten.', imageAlt: 'Drei Bediener mit Schutzhelm an ihren Pulten in der Leitwarte eines Werks, vor einem wandgroßen Schema der Anlage.' },
{ title: 'Produkte mit digitalen Elementen', body: 'Geräte, Firmware und installierbare Software, die in der EU verkauft werden. Nach dem Cyber Resilience Act steht der Hersteller über den gesamten Supportzeitraum für ihre Sicherheit ein.', imageAlt: 'Ein Ingenieur an zwei Monitoren in einem verglasten Entwicklungslabor, Engineering-Software auf dem Bildschirm und ein gefrästes Testteil auf dem Tisch.' },
{ title: 'Cloud-native Systeme', body: 'Container, Service Meshes und CI/CD-Pipelines, die Infrastruktur tragen. Sicherheit als Code, neben Ihrem.', imageAlt: 'Ein Kaltgang zwischen Serverschränken in einem Rechenzentrum, ein Techniker weit hinten.' },
],
},
proof: {
title: 'Woher wir kommen.',
items: [
{ name: 'Universität Passau', body: 'Unsere Methode ist aus akademischer Sicherheitsforschung entstanden. Die Universität bleibt unser Forschungspartner.' },
{ name: 'xG-ALOE-Allianz und xG-Incubator', body: 'Partner in der Allianz für offene Unternehmensnetze, vom Labor bis zum Betrieb.' },
{ name: 'Bundesministerium für Forschung, Technologie und Raumfahrt', body: 'Unsere Arbeit wird öffentlich gefördert.' },
],
partners: 'Vertrauen und Zusammenarbeit',
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
partners: {
title: 'Vertrauen und Zusammenarbeit',
roles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
},
quote: {
label: 'Aussagen der Gründer',
@@ -603,13 +589,13 @@ const de: typeof en = {
soon: {
eyebrow: 'Produkt',
title: ['CipherVerifier.', 'Demnächst.'],
lead: 'Eine selbst gehostete Verifikations-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
lead: 'Eine selbst gehostete Security-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
status: {
label: 'Systemstatus',
rows: [
{ key: 'Phase', value: 'Geschlossener Demonstrator', live: true },
{ key: 'Als Nächstes', value: 'Alpha-Release' },
{ key: 'Betrieb', value: 'Selbst gehostet' },
{ key: 'Fokus', value: 'Software sicherheit' },
],
},
notify: {
@@ -628,11 +614,6 @@ const de: typeof en = {
failed: 'Die Registrierung ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
},
pages: {
confirm: {
title: 'Registrierung bestätigen.',
body: 'Ein Klick, und Ihre Adresse ist für die Alpha von CipherVerifier registriert. Eine E-Mail, sobald sie erscheint. Sonst nichts.',
submit: 'Registrierung bestätigen',
},
confirmed: {
title: 'Registriert.',
body: 'Ihre Adresse ist bestätigt. Sie hören einmal von uns, sobald die Alpha von CipherVerifier erscheint.',
@@ -688,9 +669,10 @@ const de: typeof en = {
ceo: 'Gründer und CEO',
cto: 'Gründer und CTO',
engineer: 'Software Engineer',
networkengineer: 'Network & Infrastructure Engineer',
bizdev: 'Business Development',
studentF: 'Werkstudentin, Entwicklung',
studentM: 'Werkstudent, Entwicklung',
studentF: 'Mathematical Engineer',
studentM: 'Process Optimization & Automation',
},
research: {
title: ['Veröffentlichte', 'Forschung.'],
+2 -2
View File
@@ -5,7 +5,7 @@ import Manifesto from '../components/home/Manifesto.astro';
import Method from '../components/home/Method.astro';
import Stages from '../components/home/Stages.astro';
import Sectors from '../components/home/Sectors.astro';
import Proof from '../components/home/Proof.astro';
import Partners from '../components/home/Partners.astro';
import Quote from '../components/home/Quote.astro';
import Insights from '../components/home/Insights.astro';
import Contact from '../components/home/Contact.astro';
@@ -21,7 +21,7 @@ const ui = t(lang);
<Method lang={lang} />
<Stages lang={lang} />
<Sectors lang={lang} />
<Proof lang={lang} />
<Partners lang={lang} />
<Quote lang={lang} />
<Insights lang={lang} />
<Contact lang={lang} />
+2 -18
View File
@@ -1,7 +1,7 @@
---
import Base from '../layouts/Base.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; page: 'confirm' | 'confirmed' | 'error' }
interface Props { lang: Lang; page: 'confirmed' | 'error' }
const { lang, page } = Astro.props;
const p = t(lang).verifier.soon.notify.pages;
const r = p[page];
@@ -13,28 +13,12 @@ const product = localePath(lang, '/cipherverifier');
<h1 class="display result__title">{r.title}</h1>
<div class="result__body">
<p class="muted">{r.body}</p>
{page === 'confirm' ? (
// The token travels in the link's query; the script copies it into the post. The
// button, not the link, confirms: mail scanners open links but do not press buttons.
<form action="/notify/verify" method="post" data-confirm>
<input type="hidden" name="lang" value={lang} />
<input type="hidden" name="t" value="" />
<button class="button" type="submit">{p.confirm.submit}</button>
</form>
) : (
<a class="link" href={page === 'error' ? `${product}#notify` : product}>{'link' in r ? r.link : ''}</a>
)}
<a class="link" href={page === 'error' ? `${product}#notify` : product}>{r.link}</a>
</div>
</div>
</section>
</Base>
<script>
const form = document.querySelector<HTMLFormElement>('[data-confirm]');
const field = form?.querySelector<HTMLInputElement>('[name="t"]');
if (field) field.value = new URLSearchParams(location.search).get('t') ?? '';
</script>
<style>
.result__title { grid-column: 1 / -1; }
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); justify-items: start; }
-4
View File
@@ -1,4 +0,0 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="confirm" />
-4
View File
@@ -1,4 +0,0 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="confirm" />