diff --git a/README.md b/README.md index 4f1ddb1d..75a61ca0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Open `index.html` directly in any modern browser. No build step, no dependencies | **B64 URL Decode** | Decodes URL-safe Base64 strings | | **JWT Decode** | Decodes JWT header and payload (no signature verification) | | **SAML Decode** | Decodes SAML tokens — supports both HTTP-Redirect (deflate + Base64) and HTTP-POST (plain Base64) bindings, with formatted XML output | +| **PEM / Certificate Decoder** | Parses PEM-encoded X.509 certificates showing subject, SANs, issuer, validity, key usage, and fingerprints | +| **CSR Decoder** | Decodes and displays CSR fields including subject, public key, and requested extensions | ### Format @@ -29,6 +31,7 @@ Open `index.html` directly in any modern browser. No build step, no dependencies |---|---| | **JSON Beautify** | Pretty-prints and minifies JSON | | **XML Beautify** | Pretty-prints and minifies XML | +| **YAML Linter / Formatter** | Validates, beautifies, and converts between YAML and JSON with Kubernetes-standard 2-space indentation | ### Generate @@ -37,6 +40,7 @@ Open `index.html` directly in any modern browser. No build step, no dependencies | **Hash** | Generates MD5, SHA-1, SHA-256, and SHA-512 hashes | | **Password** | Cryptographically random passwords with configurable length and character sets; includes strength indicator | | **UUID** | Generates UUID v4 values (bulk generation up to 100) using `crypto.randomUUID()` | +| **CSR Generator** | Generates PKCS#10 Certificate Signing Requests with configurable subject fields and key sizes | ### Utilities @@ -47,6 +51,9 @@ Open `index.html` directly in any modern browser. No build step, no dependencies | **Timestamp** | Converts Unix timestamps (seconds/milliseconds) to human-readable dates and vice versa | | **HTTP Status** | Searchable reference table of HTTP status codes (1xx–5xx) | | **CORS Builder** | Generates `Access-Control-*` response headers from a guided form | +| **Time Zone Converter** | Converts date/time across multiple time zones with quick-add presets for common regions | +| **Keystore / Truststore Viewer** | Reads JKS and PKCS#12 keystores entirely in-browser, displaying certificate details and chain info | +| **IP / Subnet Calculator** | Calculates network address, broadcast, host range, and wildcard mask from IP and CIDR or subnet mask input | ## Technical Notes diff --git a/app.js b/app.js index 4c39b2a4..6d99f8a0 100644 --- a/app.js +++ b/app.js @@ -1,14 +1,37 @@ /* ── Tab navigation ── */ +function navigateToTab(tabId) { + document.querySelectorAll('nav button[data-tab]').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + const navBtn = document.querySelector(`nav button[data-tab="${tabId}"]`); + if (navBtn) { + navBtn.classList.add('active'); + navBtn.closest('.nav-category')?.classList.add('open'); + } + document.getElementById('tab-' + tabId).classList.add('active'); + document.querySelector('.content').scrollTop = 0; +} + document.querySelectorAll('nav button[data-tab]').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('nav button[data-tab]').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); - btn.closest('.nav-category')?.classList.add('open'); - }); + btn.addEventListener('click', () => navigateToTab(btn.dataset.tab)); }); +// Homepage card navigation +document.querySelectorAll('.home-card[data-tab]').forEach(card => { + card.addEventListener('click', () => navigateToTab(card.dataset.tab)); +}); + +// Click header to return to homepage +function showHome() { + document.querySelectorAll('nav button[data-tab]').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + document.getElementById('tab-home').classList.add('active'); + document.querySelector('.content').scrollTop = 0; +} +document.querySelector('header h1').style.cursor = 'pointer'; +document.querySelector('header h1').addEventListener('click', showHome); +document.querySelector('header img').style.cursor = 'pointer'; +document.querySelector('header img').addEventListener('click', showHome); + function toggleCategory(headerBtn) { headerBtn.closest('.nav-category').classList.toggle('open'); } @@ -833,6 +856,2272 @@ function corsGenerate() { document.getElementById('cors-output').style.display = 'block'; } +/* ── YAML Linter / Formatter ── */ +/* Structure-aware indentation fixer. Uses a stack to track parent-child + relationships via original indent changes. For sequence items (- ), + pushes TWO stack entries: one for the dash position and one for the + content column, so continuation lines are correctly re-indented. + Preserves comments, key ordering, values, and overall structure. */ +const YAMLLint = (() => { + + /* Normalize "key: value" to "key: value", preserving quoted values */ + function normalizeMappingLine(line) { + let inS = false, inD = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '\\') { i++; continue; } + if (c === '"' && !inS) inD = !inD; + if (c === "'" && !inD) inS = !inS; + if (c === ':' && !inS && !inD && (i + 1 >= line.length || line[i + 1] === ' ')) { + const key = line.slice(0, i); + const val = line.slice(i + 1).trim(); + if (val === '') return key + ':'; + return key + ': ' + val; + } + } + return line; + } + + function isMappingKey(trimmed) { + let inS = false, inD = false; + for (let i = 0; i < trimmed.length; i++) { + const c = trimmed[i]; + if (c === '\\') { i++; continue; } + if (c === '"' && !inS) inD = !inD; + if (c === "'" && !inD) inS = !inS; + if (c === ':' && !inS && !inD && (i + 1 >= trimmed.length || trimmed[i + 1] === ' ')) return true; + } + return false; + } + + function format(text, targetIndent) { + targetIndent = targetIndent || 2; + const rawLines = text.replace(/\t/g, ' ').split('\n'); + const warnings = []; + + /* + * Stack approach: + * Each entry: { origIndent, outputIndent } + * - origIndent: the original indent level this entry was created for + * - outputIndent: the corrected indent to use for lines at this level + * + * For sequence items "- content": + * 1. Push entry for the dash position (origIndent = dash column, outputIndent = computed) + * 2. Push entry for the content column (origIndent = where content starts after "- ", + * outputIndent = dashOutputIndent + 2) + * This means continuation lines at the content column get properly indented. + */ + const stack = [{ origIndent: -1, outputIndent: -targetIndent }]; + const result = []; + + let inBlockScalar = false; + let blockBaseOrigIndent = 0; + let blockBaseOutputIndent = 0; + + // Track duplicate keys per document + let keyTracker = {}; + + for (let i = 0; i < rawLines.length; i++) { + const raw = rawLines[i]; + const trimmed = raw.trim(); + const origIndent = raw.match(/^( *)/)[1].length; + const lineNum = i + 1; + + // Trailing whitespace is silently fixed by the formatter — no warning needed + // Warnings: tabs + if (raw.includes('\t')) { + warnings.push({ line: lineNum, msg: 'Tab character (converted to spaces)' }); + } + + // Blank lines + if (trimmed === '') { result.push(''); continue; } + + // Document markers + if (trimmed === '---' || trimmed === '...') { + result.push(trimmed); + stack.length = 1; + stack[0] = { origIndent: -1, outputIndent: -targetIndent }; + inBlockScalar = false; + keyTracker = {}; + continue; + } + + // Block scalar continuation: preserve content with relative indent + if (inBlockScalar) { + if (origIndent > blockBaseOrigIndent) { + const relIndent = origIndent - blockBaseOrigIndent; + result.push(' '.repeat(blockBaseOutputIndent + relIndent) + trimmed); + continue; + } + inBlockScalar = false; + } + + // Comments: use the output indent of the next non-comment line, or parent context + if (trimmed.startsWith('#')) { + // Peek ahead to find the next non-blank, non-comment line's indent context + let commentOutputIndent = 0; + // Use current stack context + const tempStack = stack.slice(); + while (tempStack.length > 1 && tempStack[tempStack.length - 1].origIndent >= origIndent) { + tempStack.pop(); + } + const parent = tempStack[tempStack.length - 1]; + if (origIndent > parent.origIndent) { + commentOutputIndent = parent.outputIndent + targetIndent; + } else { + commentOutputIndent = parent.outputIndent; + } + result.push(' '.repeat(Math.max(0, commentOutputIndent)) + trimmed); + continue; + } + + const isSeqItem = trimmed.startsWith('- ') || trimmed === '-'; + + // Pop stack entries where origIndent >= current (we've dedented or are at same level) + while (stack.length > 1 && stack[stack.length - 1].origIndent >= origIndent) { + stack.pop(); + } + + const parent = stack[stack.length - 1]; + const outputIndent = parent.outputIndent + targetIndent; + + if (isSeqItem) { + const content = trimmed === '-' ? '' : trimmed.slice(2).trim(); + + // Push dash entry + stack.push({ origIndent, outputIndent }); + + // Find where content starts in the original line (after "- " plus any extra spaces) + let contentCol = origIndent + 1; // position after '-' + while (contentCol < raw.length && raw[contentCol] === ' ') contentCol++; + if (contentCol <= origIndent + 1) contentCol = origIndent + 2; + + // Push content column entry so continuations map correctly + const contentOutputIndent = outputIndent + 2; + stack.push({ origIndent: contentCol, outputIndent: contentOutputIndent }); + + // Normalize content + let normalized = content; + if (content && isMappingKey(content)) { + normalized = normalizeMappingLine(content); + } + + // Output + if (content === '') { + result.push(' '.repeat(outputIndent) + '-'); + } else { + result.push(' '.repeat(outputIndent) + '- ' + normalized); + } + + // Check for block scalar indicator in content + if (/^[|>][+\-]?\s*$/.test(content)) { + inBlockScalar = true; + blockBaseOrigIndent = contentCol; + blockBaseOutputIndent = contentOutputIndent; + } else if (/:\s+[|>][+\-]?\s*$/.test(normalized)) { + inBlockScalar = true; + blockBaseOrigIndent = contentCol; + blockBaseOutputIndent = contentOutputIndent; + } + + // Sequence items start a new mapping scope — clear keys at the content level + // so sibling seq items can reuse the same keys (e.g. "name" in each container) + const contentDepthPrefix = contentOutputIndent + '|'; + for (const k of Object.keys(keyTracker)) { + if (k.startsWith(contentDepthPrefix)) delete keyTracker[k]; + // Also clear any deeper keys + const kDepth = parseInt(k.split('|')[0]); + if (kDepth >= contentOutputIndent) delete keyTracker[k]; + } + } else { + // Regular line (mapping key, scalar, etc.) + stack.push({ origIndent, outputIndent }); + + // Normalize + let normalized = trimmed; + if (isMappingKey(trimmed)) { + normalized = normalizeMappingLine(trimmed); + } + + result.push(' '.repeat(outputIndent) + normalized); + + // Check for block scalar indicator + if (/:\s+[|>][+\-]?\s*$/.test(normalized)) { + inBlockScalar = true; + blockBaseOrigIndent = origIndent; + blockBaseOutputIndent = outputIndent; + } + + // Duplicate key tracking + if (isMappingKey(trimmed) && !trimmed.startsWith('- ')) { + const key = normalized.split(':')[0].trim(); + const depthKey = outputIndent + '|' + key; + + // If this key has block children (key: with no inline value), + // clear deeper keys so sibling parent keys can reuse child key names + const valPart = normalized.slice(normalized.indexOf(':') + 1).trim(); + const hasChildren = valPart === '' || /^[|>][+\-]?\s*$/.test(valPart); + if (hasChildren) { + for (const k of Object.keys(keyTracker)) { + const kDepth = parseInt(k.split('|')[0]); + if (kDepth > outputIndent) delete keyTracker[k]; + } + } + + if (keyTracker[depthKey]) { + warnings.push({ line: lineNum, msg: `Duplicate key "${key}" (first at line ${keyTracker[depthKey]})` }); + } else { + keyTracker[depthKey] = lineNum; + } + } + } + } + + // Trim trailing blank lines (keep at most one) + while (result.length > 1 && result[result.length - 1] === '' && result[result.length - 2] === '') { + result.pop(); + } + + return { output: result.join('\n'), warnings }; + } + + return { format }; +})(); + +/* ── YAML Validate ── */ +function yamlValidate() { + const input = document.getElementById('yaml-in'); + const output = document.getElementById('yaml-out'); + setError('yaml-err', ''); + input.classList.remove('error'); + const val = input.value.trim(); + if (!val) { setError('yaml-err', 'Please enter YAML to validate.'); input.classList.add('error'); return; } + const indent = parseInt(document.getElementById('yaml-indent').value, 10); + const { warnings } = YAMLLint.format(val, indent); + if (warnings.length === 0) { + output.value = 'Valid YAML! No issues found.'; + } else { + output.value = 'Found ' + warnings.length + ' issue(s):\n\n' + + warnings.map(w => 'Line ' + w.line + ': ' + w.msg).join('\n'); + input.classList.add('error'); + } +} + +/* ── YAML Beautify (Fix Indentation) ── */ +function yamlBeautify() { + const input = document.getElementById('yaml-in'); + const output = document.getElementById('yaml-out'); + const indent = parseInt(document.getElementById('yaml-indent').value, 10); + setError('yaml-err', ''); + input.classList.remove('error'); + const val = input.value; + if (!val.trim()) { output.value = ''; return; } + const { output: formatted, warnings } = YAMLLint.format(val, indent); + output.value = formatted; + if (warnings.length > 0) { + setError('yaml-err', warnings.length + ' issue(s) fixed — see output. (' + + warnings.slice(0, 3).map(w => 'L' + w.line + ': ' + w.msg).join('; ') + + (warnings.length > 3 ? '; ...' : '') + ')'); + } +} + +/* ── YAML Parser (text → JS object, for YAML→JSON conversion) ── */ +const YAMLParse = (() => { + function inferType(s) { + if (s === '' || s === 'null' || s === 'Null' || s === 'NULL' || s === '~') return null; + if (s === 'true' || s === 'True' || s === 'TRUE') return true; + if (s === 'false' || s === 'False' || s === 'FALSE') return false; + if (/^[-+]?0x[0-9a-fA-F]+$/.test(s)) return parseInt(s, 16); + if (/^[-+]?0o[0-7]+$/.test(s)) return parseInt(s.replace('0o', ''), 8); + if (/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(s) && !isNaN(Number(s))) return Number(s); + if (/^[.](inf|Inf|INF)$/.test(s)) return Infinity; + if (/^-[.](inf|Inf|INF)$/.test(s)) return -Infinity; + if (/^[.](nan|NaN|NAN)$/.test(s)) return NaN; + return s; + } + + function unquote(s) { + if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') + return s.slice(1, -1).replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") + return s.slice(1, -1).replace(/''/g, "'"); + return null; + } + + function scalar(s) { const u = unquote(s); return u !== null ? u : inferType(s); } + + function splitFlow(s) { + const parts = []; let depth = 0, start = 0, inS = false, inD = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\') { i++; continue; } + if (c === '"' && !inS) inD = !inD; + if (c === "'" && !inD) inS = !inS; + if (inS || inD) continue; + if (c === '[' || c === '{') depth++; + if (c === ']' || c === '}') depth--; + if (c === ',' && depth === 0) { parts.push(s.slice(start, i)); start = i + 1; } + } + parts.push(s.slice(start)); + return parts; + } + + function flowSeq(s) { + s = s.slice(1, -1).trim(); if (!s) return []; + return splitFlow(s).map(i => { i = i.trim(); return flowVal(i); }); + } + function flowMap(s) { + s = s.slice(1, -1).trim(); if (!s) return {}; + const obj = {}; + for (const item of splitFlow(s)) { + const idx = item.indexOf(':'); + if (idx === -1) continue; + obj[scalar(item.slice(0, idx).trim())] = flowVal(item.slice(idx + 1).trim()); + } + return obj; + } + function flowVal(v) { + if (/^\{.*\}$/.test(v)) return flowMap(v); + if (/^\[.*\]$/.test(v)) return flowSeq(v); + return scalar(v); + } + + function stripComment(line) { + let inS = false, inD = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '\\') { i++; continue; } + if (c === '"' && !inS) inD = !inD; + if (c === "'" && !inD) inS = !inS; + if (c === '#' && !inS && !inD && (i === 0 || line[i - 1] === ' ')) return line.slice(0, i); + } + return line; + } + + function findColon(line) { + let inS = false, inD = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '\\') { i++; continue; } + if (c === '"' && !inS) inD = !inD; + if (c === "'" && !inD) inS = !inS; + if (c === ':' && !inS && !inD && (i + 1 >= line.length || line[i + 1] === ' ')) return i; + } + return -1; + } + + function parse(text) { + const raw = text.replace(/\t/g, ' ').split('\n'); + const lines = []; + for (let i = 0; i < raw.length; i++) { + const cleaned = stripComment(raw[i]).replace(/\s+$/, ''); + lines.push(cleaned); + } + let pos = 0; + function indent(l) { const m = lines[l].match(/^( *)/); return m ? m[1].length : 0; } + function skip() { while (pos < lines.length && lines[pos].trim() === '') pos++; } + + function parseNode(minInd) { + skip(); + if (pos >= lines.length) return undefined; + while (pos < lines.length && /^---(\s|$)/.test(lines[pos].trim())) { pos++; skip(); } + if (pos >= lines.length) return undefined; + const ci = indent(pos); + if (ci < minInd) return undefined; + const t = lines[pos].trim(); + if (/^\[.*\]$/.test(t)) { pos++; return flowSeq(t); } + if (/^\{.*\}$/.test(t)) { pos++; return flowMap(t); } + if (t.startsWith('- ') || t === '-') return parseSeq(ci); + if (findColon(t) !== -1) return parseMap(ci); + pos++; + return scalar(t); + } + + function parseMap(base) { + const obj = {}; + while (pos < lines.length) { + skip(); if (pos >= lines.length) break; + const ci = indent(pos); + if (ci !== base) break; + const t = lines[pos].trim(); + if (t.startsWith('- ')) break; + const colIdx = findColon(t); + if (colIdx === -1) break; + const key = scalar(t.slice(0, colIdx).trim()); + const valPart = t.slice(colIdx + 1).trim(); + pos++; + if (valPart === '' || valPart === undefined) { + skip(); + if (pos < lines.length && indent(pos) > base) { + obj[key] = parseNode(base + 1); + } else if (pos < lines.length && indent(pos) === base && lines[pos].trim().startsWith('- ')) { + // Sequence at same indent as parent key (common K8s pattern: "ports:\n- ...") + obj[key] = parseSeq(base); + } else { obj[key] = null; } + } else if (/^[|>][+\-]?\s*$/.test(valPart)) { + obj[key] = parseBlock(base, valPart[0] === '|' ? 'lit' : 'fold', valPart); + } else if (/^\[.*\]$/.test(valPart)) { obj[key] = flowSeq(valPart); } + else if (/^\{.*\}$/.test(valPart)) { obj[key] = flowMap(valPart); } + else { obj[key] = scalar(valPart); } + } + return obj; + } + + function parseSeq(base) { + const arr = []; + while (pos < lines.length) { + skip(); if (pos >= lines.length) break; + const ci = indent(pos); + if (ci !== base) break; + const t = lines[pos].trim(); + if (!t.startsWith('- ') && t !== '-') break; + const val = t === '-' ? '' : t.slice(2).trim(); + pos++; + if (val === '') { + skip(); + if (pos < lines.length && indent(pos) > base) arr.push(parseNode(base + 1)); + else arr.push(null); + } else if (/^[|>][+\-]?\s*$/.test(val)) { + arr.push(parseBlock(base, val[0] === '|' ? 'lit' : 'fold', val)); + } else if (/^\[.*\]$/.test(val)) { arr.push(flowSeq(val)); } + else if (/^\{.*\}$/.test(val)) { arr.push(flowMap(val)); } + else if (findColon(val) !== -1) { + // Inline mapping in seq: "- key: val" + const seqContentIndent = base + 2; + const savedLine = lines[pos - 1]; + lines[pos - 1] = ' '.repeat(seqContentIndent) + val; + pos--; + arr.push(parseMap(seqContentIndent)); + lines[pos > lines.length ? lines.length - 1 : pos - 1] = savedLine; + } else { arr.push(scalar(val)); } + } + return arr; + } + + function parseBlock(base, style, indicator) { + const chomp = indicator.endsWith('-') ? 'strip' : indicator.endsWith('+') ? 'keep' : 'clip'; + const bl = []; let bi = -1; + while (pos < lines.length) { + const r = lines[pos]; + if (r.trim() === '') { bl.push(''); pos++; continue; } + const ci = indent(pos); + if (ci <= base) break; + if (bi === -1) bi = ci; + if (ci < bi) break; + bl.push(r.slice(bi)); + pos++; + } + while (bl.length && bl[bl.length - 1] === '') bl.pop(); + let res; + if (style === 'lit') { res = bl.join('\n'); } + else { + res = ''; + for (const l of bl) { + if (l === '') res += '\n'; + else res += (res && !res.endsWith('\n') ? ' ' : '') + l; + } + } + if (chomp === 'clip') res += '\n'; + else if (chomp === 'keep') res += '\n'; + return res; + } + + // Handle multi-document: parse all documents, return array if >1 + const docs = []; + while (pos < lines.length) { + skip(); + if (pos >= lines.length) break; + if (/^---(\s|$)/.test(lines[pos].trim())) { pos++; continue; } + const doc = parseNode(0); + if (doc !== undefined) docs.push(doc); + // Skip trailing document end markers + skip(); + if (pos < lines.length && /^\.\.\.(\s|$)/.test(lines[pos].trim())) pos++; + } + if (docs.length === 0) return null; + if (docs.length === 1) return docs[0]; + return docs; + } + + return parse; +})(); + +/* ── YAML → JSON ── */ +function yamlToJson() { + const input = document.getElementById('yaml-in'); + const output = document.getElementById('yaml-out'); + const indent = parseInt(document.getElementById('yaml-indent').value, 10); + setError('yaml-err', ''); + input.classList.remove('error'); + const val = input.value.trim(); + if (!val) { output.value = ''; return; } + try { + const parsed = YAMLParse(val); + output.value = JSON.stringify(parsed, null, indent); + } catch (e) { + setError('yaml-err', 'Invalid YAML: ' + e.message); + input.classList.add('error'); + output.value = ''; + } +} + +/* ── JSON → YAML ── */ +function jsonToYaml() { + const input = document.getElementById('yaml-in'); + const output = document.getElementById('yaml-out'); + const indent = parseInt(document.getElementById('yaml-indent').value, 10); + setError('yaml-err', ''); + input.classList.remove('error'); + const val = input.value.trim(); + if (!val) { output.value = ''; return; } + try { + const parsed = JSON.parse(val); + output.value = jsonObjToYaml(parsed, indent, 0); + } catch (e) { + setError('yaml-err', 'Invalid JSON: ' + e.message); + input.classList.add('error'); + output.value = ''; + } +} + +/* Simple JSON object → YAML serializer (no full YAML parser needed) */ +function jsonObjToYaml(value, indent, depth) { + indent = indent || 2; + const pad = ' '.repeat(indent * depth); + const childPad = ' '.repeat(indent * (depth + 1)); + + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return value.toString(); + if (typeof value === 'number') return value.toString(); + if (typeof value === 'string') { + if (value === '' || value === 'null' || value === 'true' || value === 'false' || + /^[-+]?\d/.test(value) || value.includes(': ') || value.includes('#') || + value.startsWith('- ') || value.startsWith('{') || value.startsWith('[') || + value.includes('\n')) { + if (value.includes('\n')) { + const lines = value.replace(/\n$/, '').split('\n'); + return '|\n' + lines.map(l => childPad + l).join('\n'); + } + if (value.includes('"')) return "'" + value.replace(/'/g, "''") + "'"; + return '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + return value; + } + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + return value.map(item => { + if (typeof item === 'object' && item !== null && !Array.isArray(item)) { + const s = jsonObjToYaml(item, indent, depth + 1); + const mapLines = s.split('\n'); + return pad + '- ' + mapLines[0].trim() + (mapLines.length > 1 ? '\n' + mapLines.slice(1).join('\n') : ''); + } + if (Array.isArray(item)) return pad + '-\n' + jsonObjToYaml(item, indent, depth + 1); + return pad + '- ' + jsonObjToYaml(item, indent, depth + 1); + }).join('\n'); + } + if (typeof value === 'object') { + const keys = Object.keys(value); + if (keys.length === 0) return '{}'; + return keys.map(k => { + const v = value[k]; + const keyStr = /[:{}\[\],&*#?|>!'"%@`]/.test(k) || k === '' || /^\d/.test(k) ? '"' + k.replace(/"/g, '\\"') + '"' : k; + if (v === null || v === undefined) return pad + keyStr + ':'; + if (typeof v === 'object' && v !== null) return pad + keyStr + ':\n' + jsonObjToYaml(v, indent, depth + 1); + return pad + keyStr + ': ' + jsonObjToYaml(v, indent, depth + 1); + }).join('\n'); + } + return String(value); +} + +/* ── PEM / Certificate Decoder ── */ +const PEM = (() => { + // OID name lookup + const OID_NAMES = { + '2.5.4.3': 'CN', '2.5.4.4': 'SN', '2.5.4.5': 'serialNumber', + '2.5.4.6': 'C', '2.5.4.7': 'L', '2.5.4.8': 'ST', + '2.5.4.9': 'street', '2.5.4.10': 'O', '2.5.4.11': 'OU', + '2.5.4.12': 'title', '2.5.4.17': 'postalCode', '2.5.4.42': 'GN', + '2.5.4.46': 'dnQualifier', + '1.2.840.113549.1.1.1': 'RSA', + '1.2.840.113549.1.1.5': 'SHA-1 with RSA', + '1.2.840.113549.1.1.11': 'SHA-256 with RSA', + '1.2.840.113549.1.1.12': 'SHA-384 with RSA', + '1.2.840.113549.1.1.13': 'SHA-512 with RSA', + '1.2.840.113549.1.1.14': 'SHA-224 with RSA', + '1.2.840.10045.2.1': 'EC', + '1.2.840.10045.3.1.7': 'P-256 (secp256r1)', + '1.2.840.10045.4.3.2': 'ECDSA with SHA-256', + '1.2.840.10045.4.3.3': 'ECDSA with SHA-384', + '1.2.840.10045.4.3.4': 'ECDSA with SHA-512', + '1.3.101.112': 'Ed25519', + '1.3.101.113': 'Ed448', + '2.5.29.14': 'Subject Key Identifier', + '2.5.29.15': 'Key Usage', + '2.5.29.17': 'Subject Alternative Name', + '2.5.29.19': 'Basic Constraints', + '2.5.29.31': 'CRL Distribution Points', + '2.5.29.32': 'Certificate Policies', + '2.5.29.35': 'Authority Key Identifier', + '2.5.29.37': 'Extended Key Usage', + '1.3.6.1.5.5.7.1.1': 'Authority Information Access', + '1.3.6.1.5.5.7.3.1': 'TLS Web Server Authentication', + '1.3.6.1.5.5.7.3.2': 'TLS Web Client Authentication', + '1.2.840.113549.1.9.1': 'emailAddress', + '0.9.2342.19200300.100.1.25': 'DC', + }; + + const KEY_USAGE_BITS = [ + 'Digital Signature', 'Non Repudiation', 'Key Encipherment', + 'Data Encipherment', 'Key Agreement', 'Key Cert Sign', + 'CRL Sign', 'Encipher Only', 'Decipher Only' + ]; + + function parseDER(bytes, start, end) { + start = start || 0; + end = end || bytes.length; + const nodes = []; + let pos = start; + while (pos < end) { + if (pos >= bytes.length) break; + const tag = bytes[pos++]; + if (pos >= end) break; + let len = bytes[pos++]; + if (len & 0x80) { + const numBytes = len & 0x7f; + len = 0; + for (let i = 0; i < numBytes; i++) len = (len << 8) | bytes[pos++]; + } + const valueStart = pos; + const valueEnd = pos + len; + const constructed = !!(tag & 0x20); + const node = { tag, len, start: valueStart, end: valueEnd, bytes: bytes.slice(valueStart, valueEnd) }; + if (constructed) { + node.children = parseDER(bytes, valueStart, valueEnd); + } + nodes.push(node); + pos = valueEnd; + } + return nodes; + } + + function readOID(bytes) { + const parts = []; + parts.push(Math.floor(bytes[0] / 40)); + parts.push(bytes[0] % 40); + let val = 0; + for (let i = 1; i < bytes.length; i++) { + val = (val << 7) | (bytes[i] & 0x7f); + if (!(bytes[i] & 0x80)) { parts.push(val); val = 0; } + } + return parts.join('.'); + } + + function readInt(bytes) { + // Return as hex string for large numbers + if (bytes.length > 6) return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(':'); + let val = 0; + for (let i = 0; i < bytes.length; i++) val = val * 256 + bytes[i]; + return val; + } + + function readUTF8(bytes) { + return new TextDecoder().decode(new Uint8Array(bytes)); + } + + function readTime(node) { + const s = readUTF8(node.bytes); + if (node.tag === 0x17) { // UTCTime + const yy = parseInt(s.slice(0, 2)); + const year = yy >= 50 ? 1900 + yy : 2000 + yy; + return new Date(`${year}-${s.slice(2,4)}-${s.slice(4,6)}T${s.slice(6,8)}:${s.slice(8,10)}:${s.slice(10,12)}Z`); + } + // GeneralizedTime (0x18) + return new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)}Z`); + } + + function readName(node) { + // Name = SEQUENCE of SET of SEQUENCE { OID, value } + // Returns { dn: "CN=..., O=...", fields: { CN: "...", O: "...", ... } } + if (!node.children) return { dn: '', fields: {} }; + const parts = []; + const fields = {}; + for (const set of node.children) { + if (!set.children) continue; + for (const seq of set.children) { + if (!seq.children || seq.children.length < 2) continue; + const oid = readOID(seq.children[0].bytes); + const valNode = seq.children[1]; + const val = readUTF8(valNode.bytes); + const name = OID_NAMES[oid] || oid; + parts.push(name + '=' + val); + fields[name] = val; + } + } + return { dn: parts.join(', '), fields }; + } + + function readSANs(extValueBytes) { + // SANs extension value is an OCTET STRING wrapping a SEQUENCE of GeneralNames + const wrapper = parseDER(extValueBytes, 0, extValueBytes.length); + if (!wrapper.length || !wrapper[0].children) return []; + const sans = []; + for (const gn of wrapper[0].children) { + const tagNum = gn.tag & 0x1f; + const val = readUTF8(gn.bytes); + if (tagNum === 2) sans.push('DNS: ' + val); + else if (tagNum === 1) sans.push('Email: ' + val); + else if (tagNum === 6) sans.push('URI: ' + val); + else if (tagNum === 7) { + // IP address + if (gn.bytes.length === 4) { + sans.push('IP: ' + Array.from(gn.bytes).join('.')); + } else if (gn.bytes.length === 16) { + const parts = []; + for (let i = 0; i < 16; i += 2) parts.push(((gn.bytes[i] << 8) | gn.bytes[i+1]).toString(16)); + sans.push('IP: ' + parts.join(':')); + } + } else { + sans.push('Other(' + tagNum + '): ' + val); + } + } + return sans; + } + + function readKeyUsage(bytes) { + // Key Usage is a BIT STRING + const inner = parseDER(bytes, 0, bytes.length); + if (!inner.length) return []; + const bits = inner[0].bytes; + if (bits.length < 2) return []; + const unusedBits = bits[0]; + const usages = []; + for (let i = 1; i < bits.length; i++) { + for (let b = 7; b >= 0; b--) { + const bitIndex = (i - 1) * 8 + (7 - b); + if (bitIndex >= KEY_USAGE_BITS.length) break; + if (i === bits.length - 1 && (7 - b) >= (8 - unusedBits)) break; + if (bits[i] & (1 << b)) usages.push(KEY_USAGE_BITS[bitIndex]); + } + } + return usages; + } + + function readExtKeyUsage(bytes) { + const inner = parseDER(bytes, 0, bytes.length); + if (!inner.length || !inner[0].children) return []; + return inner[0].children.map(c => { + const oid = readOID(c.bytes); + return OID_NAMES[oid] || oid; + }); + } + + function readBasicConstraints(bytes) { + const inner = parseDER(bytes, 0, bytes.length); + if (!inner.length || !inner[0].children) return 'CA: false'; + const children = inner[0].children; + const isCA = children.length > 0 && children[0].tag === 0x01 && children[0].bytes[0] !== 0; + let pathLen = ''; + if (children.length > 1 && children[1].tag === 0x02) { + pathLen = ', Path Length: ' + readInt(children[1].bytes); + } + return 'CA: ' + isCA + pathLen; + } + + function getPublicKeyInfo(spki) { + if (!spki.children || spki.children.length < 2) return { algorithm: 'Unknown', bits: 0 }; + const algoSeq = spki.children[0]; + const keyBits = spki.children[1]; + let algo = 'Unknown'; + let params = ''; + if (algoSeq.children && algoSeq.children.length > 0) { + const oid = readOID(algoSeq.children[0].bytes); + algo = OID_NAMES[oid] || oid; + if (algoSeq.children.length > 1 && algoSeq.children[1].tag === 0x06) { + params = OID_NAMES[readOID(algoSeq.children[1].bytes)] || ''; + } + } + // Key size: bit string length * 8 minus unused bits indicator + const keySize = keyBits.bytes.length > 0 ? (keyBits.bytes.length - 1) * 8 : 0; + // For RSA, parse the actual modulus to get bit size + let bits = keySize; + if (algo === 'RSA' && keyBits.bytes.length > 1) { + const inner = parseDER(keyBits.bytes, 1, keyBits.bytes.length); + if (inner.length && inner[0].children && inner[0].children.length > 0) { + bits = (inner[0].children[0].bytes.length - (inner[0].children[0].bytes[0] === 0 ? 1 : 0)) * 8; + } + } + return { algorithm: algo + (params ? ' (' + params + ')' : ''), bits }; + } + + async function decode(pem) { + // Support multiple certs in a chain + const certBlocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certBlocks || certBlocks.length === 0) throw new Error('No PEM certificate found. Expected -----BEGIN CERTIFICATE----- block.'); + + const certs = []; + for (const block of certBlocks) { + const b64 = block.replace(/-----BEGIN CERTIFICATE-----/, '').replace(/-----END CERTIFICATE-----/, '').replace(/\s/g, ''); + const raw = atob(b64); + const der = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) der[i] = raw.charCodeAt(i); + + const root = parseDER(der, 0, der.length); + if (!root.length || !root[0].children || root[0].children.length < 3) throw new Error('Invalid X.509 certificate structure.'); + + const cert = root[0]; + const tbs = cert.children[0]; + const sigAlgoNode = cert.children[1]; + // const sigValue = cert.children[2]; + + if (!tbs.children || tbs.children.length < 6) throw new Error('Invalid TBSCertificate structure.'); + + let idx = 0; + // Version: [0] EXPLICIT INTEGER + let version = 1; + if ((tbs.children[idx].tag & 0xe0) === 0xa0) { + const versionNode = parseDER(tbs.children[idx].bytes, 0, tbs.children[idx].bytes.length); + version = versionNode.length ? readInt(versionNode[0].bytes) + 1 : 1; + idx++; + } + + const serial = readInt(tbs.children[idx++].bytes); + idx++; // skip signature algorithm (duplicate of outer) + const issuer = readName(tbs.children[idx++]); + const validity = tbs.children[idx++]; + const subject = readName(tbs.children[idx++]); + const spki = tbs.children[idx++]; + + const notBefore = validity.children ? readTime(validity.children[0]) : null; + const notAfter = validity.children ? readTime(validity.children[1]) : null; + + // Signature algorithm + let sigAlgo = 'Unknown'; + if (sigAlgoNode.children && sigAlgoNode.children.length > 0) { + const oid = readOID(sigAlgoNode.children[0].bytes); + sigAlgo = OID_NAMES[oid] || oid; + } + + // Public key info + const pubKeyInfo = getPublicKeyInfo(spki); + + // Extensions + const extensions = []; + const sans = []; + let keyUsage = []; + let extKeyUsage = []; + let basicConstraints = ''; + + for (let i = idx; i < tbs.children.length; i++) { + const ext = tbs.children[i]; + if ((ext.tag & 0xe0) === 0xa0 && (ext.tag & 0x1f) === 3) { + // Extensions wrapper + const extsSeq = parseDER(ext.bytes, 0, ext.bytes.length); + if (extsSeq.length && extsSeq[0].children) { + for (const extEntry of extsSeq[0].children) { + if (!extEntry.children || extEntry.children.length < 2) continue; + const extOid = readOID(extEntry.children[0].bytes); + const extName = OID_NAMES[extOid] || extOid; + const critical = extEntry.children.length > 2 && extEntry.children[1].tag === 0x01 && extEntry.children[1].bytes[0] !== 0; + const extValueNode = extEntry.children[extEntry.children.length - 1]; + + if (extOid === '2.5.29.17') { + sans.push(...readSANs(extValueNode.bytes)); + } else if (extOid === '2.5.29.15') { + keyUsage = readKeyUsage(extValueNode.bytes); + } else if (extOid === '2.5.29.37') { + extKeyUsage = readExtKeyUsage(extValueNode.bytes); + } else if (extOid === '2.5.29.19') { + basicConstraints = readBasicConstraints(extValueNode.bytes); + } + + extensions.push({ oid: extOid, name: extName, critical }); + } + } + } + } + + // Fingerprints + const sha1 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', der))) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', der))) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + + const now = new Date(); + const isExpired = notAfter && now > notAfter; + const isNotYetValid = notBefore && now < notBefore; + + certs.push({ + version, serial, subject, issuer, + notBefore, notAfter, + sigAlgo, pubKeyInfo, + sans, keyUsage, extKeyUsage, basicConstraints, + extensions, sha1, sha256, + isExpired, isNotYetValid, + }); + } + return certs; + } + + // Decode raw DER certificate byte arrays (for keystore viewer) + async function decodeDER(derArrays) { + const certs = []; + for (const der of derArrays) { + const root = parseDER(der, 0, der.length); + if (!root.length || !root[0].children || root[0].children.length < 3) continue; + + const cert = root[0]; + const tbs = cert.children[0]; + const sigAlgoNode = cert.children[1]; + + if (!tbs.children || tbs.children.length < 6) continue; + + let idx = 0; + let version = 1; + if ((tbs.children[idx].tag & 0xe0) === 0xa0) { + const versionNode = parseDER(tbs.children[idx].bytes, 0, tbs.children[idx].bytes.length); + version = versionNode.length ? readInt(versionNode[0].bytes) + 1 : 1; + idx++; + } + + const serial = readInt(tbs.children[idx++].bytes); + idx++; + const issuer = readName(tbs.children[idx++]); + const validity = tbs.children[idx++]; + const subject = readName(tbs.children[idx++]); + const spki = tbs.children[idx++]; + + const notBefore = validity.children ? readTime(validity.children[0]) : null; + const notAfter = validity.children ? readTime(validity.children[1]) : null; + + let sigAlgo = 'Unknown'; + if (sigAlgoNode.children && sigAlgoNode.children.length > 0) { + const oid = readOID(sigAlgoNode.children[0].bytes); + sigAlgo = OID_NAMES[oid] || oid; + } + + const pubKeyInfo = getPublicKeyInfo(spki); + + const sans = []; + let keyUsage = []; + let extKeyUsage = []; + let basicConstraints = ''; + + for (let i = idx; i < tbs.children.length; i++) { + const ext = tbs.children[i]; + if ((ext.tag & 0xe0) === 0xa0 && (ext.tag & 0x1f) === 3) { + const extsSeq = parseDER(ext.bytes, 0, ext.bytes.length); + if (extsSeq.length && extsSeq[0].children) { + for (const extEntry of extsSeq[0].children) { + if (!extEntry.children || extEntry.children.length < 2) continue; + const extOid = readOID(extEntry.children[0].bytes); + const extValueNode = extEntry.children[extEntry.children.length - 1]; + if (extOid === '2.5.29.17') sans.push(...readSANs(extValueNode.bytes)); + else if (extOid === '2.5.29.15') keyUsage = readKeyUsage(extValueNode.bytes); + else if (extOid === '2.5.29.37') extKeyUsage = readExtKeyUsage(extValueNode.bytes); + else if (extOid === '2.5.29.19') basicConstraints = readBasicConstraints(extValueNode.bytes); + } + } + } + } + + const sha1 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', der))) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', der))) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + + const now = new Date(); + certs.push({ + version, serial, subject, issuer, + notBefore, notAfter, sigAlgo, pubKeyInfo, + sans, keyUsage, extKeyUsage, basicConstraints, + sha1, sha256, + isExpired: notAfter && now > notAfter, + isNotYetValid: notBefore && now < notBefore, + }); + } + return certs; + } + + return { decode, decodeDER, parseDER, readOID, readUTF8 }; +})(); + +async function pemDecode() { + const input = document.getElementById('pem-in'); + const resultsEl = document.getElementById('pem-results'); + setError('pem-err', ''); + input.classList.remove('error'); + const val = input.value.trim(); + if (!val) { setError('pem-err', 'Please paste a PEM certificate.'); input.classList.add('error'); return; } + + try { + const certs = await PEM.decode(val); + const esc = s => String(s).replace(/&/g, '&').replace(//g, '>'); + const nameFieldLabels = { + CN: 'Common Name', O: 'Organization', OU: 'Org Unit', + L: 'Locality', ST: 'State', C: 'Country', + emailAddress: 'Email', DC: 'Domain Component', + serialNumber: 'Serial No.', SN: 'Surname', GN: 'Given Name', + street: 'Street', postalCode: 'Postal Code', title: 'Title', + dnQualifier: 'DN Qualifier', + }; + const nameFieldOrder = ['CN', 'O', 'OU', 'L', 'ST', 'C', 'emailAddress', 'DC']; + + function renderNameFields(nameObj) { + const f = nameObj.fields; + const allKeys = nameFieldOrder.filter(k => f[k]).concat(Object.keys(f).filter(k => !nameFieldOrder.includes(k))); + return allKeys.map(k => { + const label = nameFieldLabels[k] || k; + return `
${esc(label)}${esc(f[k])}
`; + }).join(''); + } + + resultsEl.innerHTML = certs.map((cert, i) => { + const statusCls = cert.isExpired ? 'pem-expired' : cert.isNotYetValid ? 'pem-expired' : 'pem-valid'; + const statusMsg = cert.isExpired ? 'EXPIRED' : cert.isNotYetValid ? 'NOT YET VALID' : 'VALID'; + const title = certs.length > 1 ? `
Certificate ${i + 1} of ${certs.length}
` : ''; + return `${title}
+
${statusMsg}
+
+
Subject
+ ${renderNameFields(cert.subject)} +
+ ${cert.sans.length ? `
+
Subject Alternative Names (${cert.sans.length})
+
${cert.sans.map(s => `${esc(s)}`).join('')}
+
` : ''} +
+
Issuer
+ ${renderNameFields(cert.issuer)} +
+
+
Validity
+
Valid From${cert.notBefore ? cert.notBefore.toUTCString() : 'N/A'}
+
Valid To${cert.notAfter ? cert.notAfter.toUTCString() : 'N/A'}
+
+
+
Details
+
VersionV${cert.version}
+
Serial Number${esc(String(cert.serial))}
+
Signature Algo${esc(cert.sigAlgo)}
+
Public Key${esc(cert.pubKeyInfo.algorithm)} (${cert.pubKeyInfo.bits} bit)
+ ${cert.basicConstraints ? `
Basic Constraints${esc(cert.basicConstraints)}
` : ''} + ${cert.keyUsage.length ? `
Key Usage${cert.keyUsage.map(esc).join(', ')}
` : ''} + ${cert.extKeyUsage.length ? `
Ext Key Usage${cert.extKeyUsage.map(esc).join(', ')}
` : ''} +
+
+
Fingerprints
+
SHA-1${cert.sha1} +
+
SHA-256${cert.sha256} +
+
+
`; + }).join(''); + resultsEl.style.display = 'flex'; + } catch (e) { + setError('pem-err', e.message); + input.classList.add('error'); + resultsEl.style.display = 'none'; + } +} + +function clearPem() { + document.getElementById('pem-in').value = ''; + document.getElementById('pem-in').classList.remove('error'); + setError('pem-err', ''); + const r = document.getElementById('pem-results'); + r.style.display = 'none'; r.innerHTML = ''; +} + +/* ── CSR Decoder ── */ +async function csrDec() { + const input = document.getElementById('csrdec-in'); + const resultsEl = document.getElementById('csrdec-results'); + setError('csrdec-err', ''); + input.classList.remove('error'); + resultsEl.style.display = 'none'; + resultsEl.innerHTML = ''; + + const val = input.value.trim(); + if (!val) { setError('csrdec-err', 'Please paste a PEM CSR.'); input.classList.add('error'); return; } + + try { + const b64 = val.replace(/-----BEGIN (NEW )?CERTIFICATE REQUEST-----/, '') + .replace(/-----END (NEW )?CERTIFICATE REQUEST-----/, '') + .replace(/\s/g, ''); + if (!b64) throw new Error('No CSR data found. Expected -----BEGIN CERTIFICATE REQUEST----- block.'); + const raw = atob(b64); + const der = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) der[i] = raw.charCodeAt(i); + + const root = PEM.parseDER(der, 0, der.length); + if (!root.length || !root[0].children || root[0].children.length < 3) + throw new Error('Invalid PKCS#10 CSR structure.'); + + const csr = root[0]; + const certReqInfo = csr.children[0]; // CertificationRequestInfo + const sigAlgoNode = csr.children[1]; + + if (!certReqInfo.children || certReqInfo.children.length < 3) + throw new Error('Invalid CertificationRequestInfo.'); + + // Version + let idx = 0; + const version = certReqInfo.children[idx].bytes[0]; + idx++; + + // Subject + const subjectNode = certReqInfo.children[idx++]; + const subject = parseCSRName(subjectNode); + + // SubjectPublicKeyInfo + const spki = certReqInfo.children[idx++]; + const pubKeyInfo = parseCSRPublicKey(spki); + + // Attributes [0] — may contain SANs extension request + let sans = []; + if (idx < certReqInfo.children.length) { + const attrs = certReqInfo.children[idx]; + if ((attrs.tag & 0xe0) === 0xa0) { + const attrNodes = PEM.parseDER(attrs.bytes, 0, attrs.bytes.length); + for (const attr of attrNodes) { + if (!attr.children || attr.children.length < 2) continue; + const attrOid = PEM.readOID(attr.children[0].bytes); + if (attrOid === '1.2.840.113549.1.9.14') { + // extensionRequest + const extSet = attr.children[1]; + const extSeqNodes = extSet.children || PEM.parseDER(extSet.bytes, 0, extSet.bytes.length); + for (const extSeq of extSeqNodes) { + const exts = extSeq.children || PEM.parseDER(extSeq.bytes, 0, extSeq.bytes.length); + for (const ext of exts) { + if (!ext.children || ext.children.length < 2) continue; + const extOid = PEM.readOID(ext.children[0].bytes); + if (extOid === '2.5.29.17') { + // SAN + const sanValue = ext.children[ext.children.length - 1]; + const sanBytes = sanValue.tag === 0x04 ? sanValue.bytes : sanValue.bytes; + const sanSeq = PEM.parseDER(sanBytes, 0, sanBytes.length); + if (sanSeq.length && sanSeq[0].children) { + for (const gn of sanSeq[0].children) { + const tagNum = gn.tag & 0x1f; + if (tagNum === 2) sans.push('DNS: ' + PEM.readUTF8(gn.bytes)); + else if (tagNum === 7 && gn.bytes.length === 4) sans.push('IP: ' + Array.from(gn.bytes).join('.')); + else if (tagNum === 7 && gn.bytes.length === 16) { + const p = []; + for (let i = 0; i < 16; i += 2) p.push(((gn.bytes[i] << 8) | gn.bytes[i+1]).toString(16)); + sans.push('IP: ' + p.join(':')); + } + else if (tagNum === 1) sans.push('Email: ' + PEM.readUTF8(gn.bytes)); + else if (tagNum === 6) sans.push('URI: ' + PEM.readUTF8(gn.bytes)); + } + } + } + } + } + } + } + } + } + + // Signature algorithm + let sigAlgo = 'Unknown'; + const OID_SIG = { + '1.2.840.113549.1.1.5': 'SHA-1 with RSA', + '1.2.840.113549.1.1.11': 'SHA-256 with RSA', + '1.2.840.113549.1.1.12': 'SHA-384 with RSA', + '1.2.840.113549.1.1.13': 'SHA-512 with RSA', + '1.2.840.10045.4.3.2': 'ECDSA with SHA-256', + '1.2.840.10045.4.3.3': 'ECDSA with SHA-384', + '1.2.840.10045.4.3.4': 'ECDSA with SHA-512', + }; + if (sigAlgoNode.children && sigAlgoNode.children.length > 0) { + const oid = PEM.readOID(sigAlgoNode.children[0].bytes); + sigAlgo = OID_SIG[oid] || oid; + } + + // Fingerprint + const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', der))) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + + // Render + const esc = s => String(s).replace(/&/g, '&').replace(//g, '>'); + const nameLabels = { + CN: 'Common Name', O: 'Organization', OU: 'Department', + L: 'City', ST: 'State / Province', C: 'Country', + emailAddress: 'Email', DC: 'Domain Component', + }; + const nameOrder = ['CN', 'O', 'OU', 'L', 'ST', 'C', 'emailAddress', 'DC']; + + function renderFields(fields) { + const keys = nameOrder.filter(k => fields[k]).concat(Object.keys(fields).filter(k => !nameOrder.includes(k))); + return keys.map(k => { + const label = nameLabels[k] || k; + return `
${esc(label)}${esc(fields[k])}
`; + }).join(''); + } + + resultsEl.innerHTML = `
+
Subject
${renderFields(subject)}
+ ${sans.length ? `
Subject Alternative Names (${sans.length})
+
${sans.map(s => `${esc(s)}`).join('')}
` : ''} +
Public Key
+
Algorithm${esc(pubKeyInfo.algorithm)}
+
Key Size${pubKeyInfo.bits} bit
+
+
Details
+
Version${version === 0 ? 'v1 (0)' : 'v' + (version + 1)}
+
Signature Algo${esc(sigAlgo)}
+
+
Fingerprint
+
SHA-256${sha256} +
+
+
`; + resultsEl.style.display = 'flex'; + } catch (e) { + setError('csrdec-err', e.message); + input.classList.add('error'); + } +} + +function parseCSRName(node) { + const OID_NAMES = { + '2.5.4.3': 'CN', '2.5.4.6': 'C', '2.5.4.7': 'L', '2.5.4.8': 'ST', + '2.5.4.10': 'O', '2.5.4.11': 'OU', '1.2.840.113549.1.9.1': 'emailAddress', + '0.9.2342.19200300.100.1.25': 'DC', + }; + const fields = {}; + if (!node.children) return fields; + for (const set of node.children) { + if (!set.children) continue; + for (const seq of set.children) { + if (!seq.children || seq.children.length < 2) continue; + const oid = PEM.readOID(seq.children[0].bytes); + const val = PEM.readUTF8(seq.children[1].bytes); + const name = OID_NAMES[oid] || oid; + fields[name] = val; + } + } + return fields; +} + +function parseCSRPublicKey(spki) { + const OID_ALGO = { + '1.2.840.113549.1.1.1': 'RSA', + '1.2.840.10045.2.1': 'EC', + '1.3.101.112': 'Ed25519', + }; + const OID_CURVES = { + '1.2.840.10045.3.1.7': 'P-256', + '1.3.132.0.34': 'P-384', + '1.3.132.0.35': 'P-521', + }; + if (!spki.children || spki.children.length < 2) return { algorithm: 'Unknown', bits: 0 }; + const algoSeq = spki.children[0]; + const keyBits = spki.children[1]; + let algo = 'Unknown', params = ''; + if (algoSeq.children && algoSeq.children.length > 0) { + const oid = PEM.readOID(algoSeq.children[0].bytes); + algo = OID_ALGO[oid] || oid; + if (algoSeq.children.length > 1 && algoSeq.children[1].tag === 0x06) { + const curveOid = PEM.readOID(algoSeq.children[1].bytes); + params = OID_CURVES[curveOid] || curveOid; + } + } + let bits = keyBits.bytes.length > 0 ? (keyBits.bytes.length - 1) * 8 : 0; + if (algo === 'RSA' && keyBits.bytes.length > 1) { + const inner = PEM.parseDER(keyBits.bytes, 1, keyBits.bytes.length); + if (inner.length && inner[0].children && inner[0].children.length > 0) { + bits = (inner[0].children[0].bytes.length - (inner[0].children[0].bytes[0] === 0 ? 1 : 0)) * 8; + } + } + const display = algo + (params ? ' (' + params + ')' : ''); + return { algorithm: display, bits }; +} + +function clearCsrDec() { + document.getElementById('csrdec-in').value = ''; + document.getElementById('csrdec-in').classList.remove('error'); + setError('csrdec-err', ''); + const r = document.getElementById('csrdec-results'); + r.style.display = 'none'; r.innerHTML = ''; +} + + +/* ── CSR Generator ── */ +const CSR = (() => { + // ASN.1 DER encoding helpers + function encodeLength(len) { + if (len < 0x80) return [len]; + if (len < 0x100) return [0x81, len]; + return [0x82, (len >> 8) & 0xff, len & 0xff]; + } + + function encodeTLV(tag, value) { + const v = value instanceof Uint8Array ? Array.from(value) : value; + return [tag, ...encodeLength(v.length), ...v]; + } + + function encodeSequence(children) { + const body = children.flat(); + return encodeTLV(0x30, body); + } + + function encodeSet(children) { + const body = children.flat(); + return encodeTLV(0x31, body); + } + + function encodeOID(oid) { + const parts = oid.split('.').map(Number); + const bytes = [parts[0] * 40 + parts[1]]; + for (let i = 2; i < parts.length; i++) { + let val = parts[i]; + if (val < 128) { bytes.push(val); } + else { + const chunks = []; + while (val > 0) { chunks.unshift(val & 0x7f); val >>= 7; } + for (let j = 0; j < chunks.length - 1; j++) chunks[j] |= 0x80; + bytes.push(...chunks); + } + } + return encodeTLV(0x06, bytes); + } + + function encodeUTF8String(str) { + const bytes = new TextEncoder().encode(str); + return encodeTLV(0x0c, Array.from(bytes)); + } + + function encodePrintableString(str) { + const bytes = []; + for (let i = 0; i < str.length; i++) bytes.push(str.charCodeAt(i)); + return encodeTLV(0x13, bytes); + } + + function encodeIA5String(str) { + const bytes = []; + for (let i = 0; i < str.length; i++) bytes.push(str.charCodeAt(i)); + return encodeTLV(0x16, bytes); + } + + function encodeBitString(bytes) { + // Prepend 0 unused bits + return encodeTLV(0x03, [0, ...Array.from(bytes)]); + } + + function encodeInteger(bytes) { + const arr = Array.from(bytes); + // Ensure positive (add leading 0 if high bit set) + if (arr[0] & 0x80) arr.unshift(0); + return encodeTLV(0x02, arr); + } + + // OIDs for subject fields + const OIDs = { + CN: '2.5.4.3', O: '2.5.4.10', OU: '2.5.4.11', + L: '2.5.4.7', ST: '2.5.4.8', C: '2.5.4.6', + email: '1.2.840.113549.1.9.1', + }; + + const SIG_OIDS = { + 'RSA-SHA-256': '1.2.840.113549.1.1.11', + 'RSA-SHA-384': '1.2.840.113549.1.1.12', + 'RSA-SHA-512': '1.2.840.113549.1.1.13', + 'EC-SHA-256': '1.2.840.10045.4.3.2', + 'EC-SHA-384': '1.2.840.10045.4.3.3', + 'EC-SHA-512': '1.2.840.10045.4.3.4', + }; + + function buildSubject(fields) { + const rdns = []; + const order = ['CN', 'O', 'OU', 'L', 'ST', 'C', 'email']; + for (const key of order) { + const val = fields[key]; + if (!val) continue; + const oid = encodeOID(OIDs[key]); + let valEnc; + if (key === 'C') valEnc = encodePrintableString(val.toUpperCase()); + else if (key === 'email') valEnc = encodeIA5String(val); + else valEnc = encodeUTF8String(val); + rdns.push(encodeSet([encodeSequence([oid, valEnc])])); + } + return encodeSequence(rdns); + } + + function buildSANExtension(sans) { + if (!sans.length) return null; + const generalNames = []; + for (const san of sans) { + const trimmed = san.trim(); + if (!trimmed) continue; + // Detect IP vs DNS + const isIP = /^\d{1,3}(\.\d{1,3}){3}$/.test(trimmed); + if (isIP) { + const octets = trimmed.split('.').map(Number); + generalNames.push(encodeTLV(0x87, octets)); // [7] iPAddress + } else { + const bytes = new TextEncoder().encode(trimmed); + generalNames.push(encodeTLV(0x82, Array.from(bytes))); // [2] dNSName + } + } + if (!generalNames.length) return null; + const sanSeq = encodeSequence(generalNames); + const extReqValue = encodeSequence([ + encodeSequence([ + encodeOID('2.5.29.17'), // SAN OID + encodeTLV(0x04, sanSeq), // OCTET STRING wrapping + ]) + ]); + // Wrap as [0] EXPLICIT for extensionRequest attribute + return encodeSequence([ + encodeOID('1.2.840.113549.1.9.14'), // extensionRequest OID + encodeSet([extReqValue]), + ]); + } + + async function generate(options) { + const { cn, o, ou, l, st, c, email, sans, algo, sigHash } = options; + if (!cn) throw new Error('Common Name (CN) is required.'); + + const isEC = algo.startsWith('EC'); + let keyPair, spkiBytes; + + if (isEC) { + const curve = algo === 'EC-P256' ? 'P-256' : 'P-384'; + keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: curve }, + true, ['sign', 'verify'] + ); + } else { + const bits = algo === 'RSA-2048' ? 2048 : 4096; + keyPair = await crypto.subtle.generateKey( + { name: 'RSASSA-PKCS1-v1_5', modulusLength: bits, publicExponent: new Uint8Array([1, 0, 1]), hash: sigHash }, + true, ['sign', 'verify'] + ); + } + + // Export SPKI (public key) + const spkiRaw = await crypto.subtle.exportKey('spki', keyPair.publicKey); + spkiBytes = new Uint8Array(spkiRaw); + + // Export PKCS#8 (private key) + const pkcs8Raw = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const pkcs8Bytes = new Uint8Array(pkcs8Raw); + + // Build subject + const subject = buildSubject({ CN: cn, O: o, OU: ou, L: l, ST: st, C: c, email }); + + // Build CertificationRequestInfo + const version = encodeTLV(0x02, [0]); // version 0 + + // Attributes (SANs if any) + const sanExt = buildSANExtension(sans); + let attributes; + if (sanExt) { + // [0] IMPLICIT SET OF Attribute + const attrBody = sanExt; + attributes = encodeTLV(0xa0, attrBody); + } else { + attributes = encodeTLV(0xa0, []); // empty attributes + } + + const certReqInfo = encodeSequence([ + version, + subject, + Array.from(spkiBytes), + attributes, + ]); + + // Sign the certReqInfo + const certReqInfoBytes = new Uint8Array(certReqInfo); + let signature; + if (isEC) { + const sigRaw = await crypto.subtle.sign( + { name: 'ECDSA', hash: sigHash }, + keyPair.privateKey, certReqInfoBytes + ); + // Convert from WebCrypto IEEE P1363 format to ASN.1 DER format + signature = ieeeToAsn1Sig(new Uint8Array(sigRaw)); + } else { + const sigRaw = await crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, certReqInfoBytes + ); + signature = new Uint8Array(sigRaw); + } + + // Signature algorithm + const sigAlgoKey = (isEC ? 'EC' : 'RSA') + '-' + sigHash; + const sigOid = SIG_OIDS[sigAlgoKey]; + let sigAlgoSeq; + if (isEC) { + sigAlgoSeq = encodeSequence([encodeOID(sigOid)]); + } else { + sigAlgoSeq = encodeSequence([encodeOID(sigOid), encodeTLV(0x05, [])]); // NULL params + } + + // Build final CSR + const csr = encodeSequence([ + Array.from(certReqInfoBytes), + sigAlgoSeq, + encodeBitString(signature), + ]); + + const csrPem = toPEM(new Uint8Array(csr), 'CERTIFICATE REQUEST'); + const keyPem = toPEM(pkcs8Bytes, 'PRIVATE KEY'); + + return { csrPem, keyPem }; + } + + // Convert ECDSA signature from IEEE P1363 (r||s) to ASN.1 DER + function ieeeToAsn1Sig(raw) { + const half = raw.length / 2; + let r = raw.slice(0, half); + let s = raw.slice(half); + // Trim leading zeros but keep one if high bit set + function trimInt(bytes) { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + const trimmed = bytes.slice(i); + if (trimmed[0] & 0x80) { + const padded = new Uint8Array(trimmed.length + 1); + padded.set(trimmed, 1); + return padded; + } + return trimmed; + } + r = trimInt(r); + s = trimInt(s); + const rEnc = encodeTLV(0x02, Array.from(r)); + const sEnc = encodeTLV(0x02, Array.from(s)); + return new Uint8Array(encodeSequence([rEnc, sEnc])); + } + + function toPEM(der, label) { + const b64 = btoa(String.fromCharCode(...der)); + const lines = b64.match(/.{1,64}/g) || []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`; + } + + return { generate }; +})(); + +function csrAlgoChange() { + // Adjust signature options based on key type — all hashes work for both +} + +async function csrGenerate() { + setError('csr-err', ''); + const resultsEl = document.getElementById('csr-results'); + resultsEl.style.display = 'none'; + + const cn = document.getElementById('csr-cn').value.trim(); + const o = document.getElementById('csr-o').value.trim(); + const ou = document.getElementById('csr-ou').value.trim(); + const l = document.getElementById('csr-l').value.trim(); + const st = document.getElementById('csr-st').value.trim(); + const c = document.getElementById('csr-c').value.trim(); + const email = document.getElementById('csr-email').value.trim(); + const sansRaw = document.getElementById('csr-sans').value.trim(); + const sans = sansRaw ? sansRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + const algo = document.getElementById('csr-algo').value; + const sigHash = document.getElementById('csr-sig').value; + + try { + const { csrPem, keyPem } = await CSR.generate({ cn, o, ou, l, st, c, email, sans, algo, sigHash }); + document.getElementById('csr-out').value = csrPem; + document.getElementById('csr-key-out').value = keyPem; + resultsEl.style.display = 'flex'; + } catch (e) { + setError('csr-err', e.message); + } +} + +function csrCopyCSR() { + const text = document.getElementById('csr-out').value; + if (!text) { showToast('Nothing to copy'); return; } + navigator.clipboard.writeText(text).then(() => showToast('CSR copied!')); +} + +function csrCopyKey() { + const text = document.getElementById('csr-key-out').value; + if (!text) { showToast('Nothing to copy'); return; } + navigator.clipboard.writeText(text).then(() => showToast('Private key copied!')); +} + +function csrDownload(type) { + const text = type === 'csr' ? document.getElementById('csr-out').value : document.getElementById('csr-key-out').value; + if (!text) return; + const cn = document.getElementById('csr-cn').value.trim().replace(/[^a-zA-Z0-9.-]/g, '_') || 'certificate'; + const filename = type === 'csr' ? `${cn}.csr` : `${cn}.key`; + const blob = new Blob([text], { type: 'text/plain' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +} + +function csrClear() { + ['csr-cn', 'csr-o', 'csr-ou', 'csr-l', 'csr-st', 'csr-c', 'csr-email', 'csr-sans'].forEach(id => { + document.getElementById(id).value = ''; + }); + document.getElementById('csr-out').value = ''; + document.getElementById('csr-key-out').value = ''; + document.getElementById('csr-results').style.display = 'none'; + setError('csr-err', ''); +} + + +/* ── Keystore / Truststore Viewer ── */ +const KEYSTORE = (() => { + + // Detect file format from magic bytes + function detectFormat(buf) { + const u8 = new Uint8Array(buf); + // JKS magic: 0xFEEDFEED + if (u8.length > 4 && u8[0] === 0xFE && u8[1] === 0xED && u8[2] === 0xFE && u8[3] === 0xED) return 'jks'; + // PEM text + if (u8.length > 10) { + const head = new TextDecoder().decode(u8.slice(0, 30)); + if (head.includes('-----BEGIN')) return 'pem'; + } + // PKCS#12: starts with ASN.1 SEQUENCE + if (u8.length > 2 && u8[0] === 0x30) return 'p12'; + return 'unknown'; + } + + // ── JKS Parser ── + // JKS stores trusted certs in the clear; password only for integrity check. + function parseJKS(buf) { + const dv = new DataView(buf); + const u8 = new Uint8Array(buf); + let pos = 0; + + const magic = dv.getUint32(pos); pos += 4; + if (magic !== 0xFEEDFEED) throw new Error('Not a valid JKS file (bad magic bytes).'); + + const version = dv.getUint32(pos); pos += 4; + const entryCount = dv.getUint32(pos); pos += 4; + + const entries = []; + for (let e = 0; e < entryCount; e++) { + const tag = dv.getUint32(pos); pos += 4; // 1=privateKey, 2=trustedCert + + // Alias (UTF-16 length-prefixed) + const aliasLen = dv.getUint16(pos); pos += 2; + const aliasBytes = u8.slice(pos, pos + aliasLen); + const alias = new TextDecoder().decode(aliasBytes); + pos += aliasLen; + + // Timestamp (8 bytes, ms since epoch) + const tsHi = dv.getUint32(pos); pos += 4; + const tsLo = dv.getUint32(pos); pos += 4; + const timestamp = new Date(tsHi * 0x100000000 + tsLo); + + const derCerts = []; + + if (tag === 2) { + // Trusted certificate entry + const certTypeLen = dv.getUint16(pos); pos += 2; + pos += certTypeLen; // skip cert type string ("X.509") + const certDataLen = dv.getUint32(pos); pos += 4; + derCerts.push(new Uint8Array(buf.slice(pos, pos + certDataLen))); + pos += certDataLen; + } else if (tag === 1) { + // Private key entry — skip encrypted key, read cert chain + const keyLen = dv.getUint32(pos); pos += 4; + pos += keyLen; // skip encrypted private key + const chainLen = dv.getUint32(pos); pos += 4; + for (let c = 0; c < chainLen; c++) { + const cTypeLen = dv.getUint16(pos); pos += 2; + pos += cTypeLen; + const cDataLen = dv.getUint32(pos); pos += 4; + derCerts.push(new Uint8Array(buf.slice(pos, pos + cDataLen))); + pos += cDataLen; + } + } + + entries.push({ + alias, + type: tag === 2 ? 'Trusted Certificate' : 'Private Key + Certificate Chain', + timestamp, + derCerts, + }); + } + return entries; + } + + // ── PKCS#12 Parser ── + // Implements PKCS#12 KDF (RFC 7292 App B) + 3DES-CBC for legacy keystores. + + // SHA-1 (sync, for KDF — Web Crypto is async so we need our own for the tight KDF loop) + function sha1(data) { + let h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0; + const msg = new Uint8Array(data); + const bitLen = msg.length * 8; + // Pre-processing: padding + const padded = new Uint8Array(Math.ceil((msg.length + 9) / 64) * 64); + padded.set(msg); + padded[msg.length] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(padded.length - 4, bitLen, false); + + function rotl(n, s) { return ((n << s) | (n >>> (32 - s))) >>> 0; } + + for (let i = 0; i < padded.length; i += 64) { + const w = new Uint32Array(80); + for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false); + for (let j = 16; j < 80; j++) w[j] = rotl(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1); + + let a = h0, b = h1, c = h2, d = h3, e = h4; + for (let j = 0; j < 80; j++) { + let f, k; + if (j < 20) { f = (b & c) | ((~b) & d); k = 0x5A827999; } + else if (j < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1; } + else if (j < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDC; } + else { f = b ^ c ^ d; k = 0xCA62C1D6; } + const temp = (rotl(a, 5) + f + e + k + w[j]) >>> 0; + e = d; d = c; c = rotl(b, 30); b = a; a = temp; + } + h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; h4 = (h4 + e) >>> 0; + } + const out = new Uint8Array(20); + const odv = new DataView(out.buffer); + odv.setUint32(0, h0); odv.setUint32(4, h1); odv.setUint32(8, h2); + odv.setUint32(12, h3); odv.setUint32(16, h4); + return out; + } + + // PKCS#12 KDF (RFC 7292 Appendix B) + function pkcs12KDF(password, salt, iterations, id, keyLen) { + const v = 64; // SHA-1 block size + const u = 20; // SHA-1 digest size + + // D = id repeated v times + const D = new Uint8Array(v); + D.fill(id); + + // Convert password to BMPString (big-endian UTF-16) + 2 null bytes + const passBytes = new Uint8Array(password.length * 2 + 2); + for (let i = 0; i < password.length; i++) { + const code = password.charCodeAt(i); + passBytes[i * 2] = (code >> 8) & 0xff; + passBytes[i * 2 + 1] = code & 0xff; + } + // Last 2 bytes are already 0 + + function fillAndCeil(input) { + if (input.length === 0) return new Uint8Array(0); + const len = Math.ceil(input.length / v) * v; + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) out[i] = input[i % input.length]; + return out; + } + + const S = fillAndCeil(salt); + const P = fillAndCeil(passBytes); + const I = new Uint8Array(S.length + P.length); + I.set(S); I.set(P, S.length); + + let result = new Uint8Array(0); + while (result.length < keyLen) { + // Hash: SHA1(D || I) iterated + const toHash = new Uint8Array(v + I.length); + toHash.set(D); toHash.set(I, v); + let A = sha1(toHash); + for (let i = 1; i < iterations; i++) A = sha1(A); + + // Append A to result + const newResult = new Uint8Array(result.length + A.length); + newResult.set(result); newResult.set(A, result.length); + result = newResult; + + if (result.length >= keyLen) break; + + // Update I: B = A repeated to v bytes, then I_j = (I_j + B + 1) mod 2^v + const B = new Uint8Array(v); + for (let i = 0; i < v; i++) B[i] = A[i % u]; + for (let j = 0; j < I.length; j += v) { + let carry = 1; + for (let k = v - 1; k >= 0; k--) { + const sum = I[j + k] + B[k] + carry; + I[j + k] = sum & 0xff; + carry = sum >> 8; + } + } + } + return result.slice(0, keyLen); + } + + // ── DES Implementation (for 3DES-CBC) ── + // Initial permutation table + const IP = [58,50,42,34,26,18,10,2,60,52,44,36,28,20,12,4,62,54,46,38,30,22,14,6,64,56,48,40,32,24,16,8, + 57,49,41,33,25,17,9,1,59,51,43,35,27,19,11,3,61,53,45,37,29,21,13,5,63,55,47,39,31,23,15,7]; + const FP = [40,8,48,16,56,24,64,32,39,7,47,15,55,23,63,31,38,6,46,14,54,22,62,30,37,5,45,13,53,21,61,29, + 36,4,44,12,52,20,60,28,35,3,43,11,51,19,59,27,34,2,42,10,50,18,58,26,33,1,41,9,49,17,57,25]; + const E = [32,1,2,3,4,5,4,5,6,7,8,9,8,9,10,11,12,13,12,13,14,15,16,17,16,17,18,19,20,21,20,21,22,23,24,25, + 24,25,26,27,28,29,28,29,30,31,32,1]; + const P = [16,7,20,21,29,12,28,17,1,15,23,26,5,18,31,10,2,8,24,14,32,27,3,9,19,13,30,6,22,11,4,25]; + const S = [ + [14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7,0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8,4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0,15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13], + [15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10,3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5,0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15,13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9], + [10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8,13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1,13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7,1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12], + [7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15,13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9,10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4,3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14], + [2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9,14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6,4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14,11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3], + [12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11,10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8,9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6,4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13], + [4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1,13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6,1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2,6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12], + [13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7,1,15,13,8,10,3,7,4,12,5,6,2,0,14,9,11,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1,13,11,14,1,7,4,0,9,12,14,2,3,5,10,15,6,8] + ]; + const PC1 = [57,49,41,33,25,17,9,1,58,50,42,34,26,18,10,2,59,51,43,35,27,19,11,3,60,52,44,36, + 63,55,47,39,31,23,15,7,62,54,46,38,30,22,14,6,61,53,45,37,29,21,13,5,28,20,12,4]; + const PC2 = [14,17,11,24,1,5,3,28,15,6,21,10,23,19,12,4,26,8,16,7,27,20,13,2,41,52,31,37,47,55,30,40,51,45,33,48,44,49,39,56,34,53,46,42,50,36,29,32]; + const SHIFTS = [1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1]; + + function permute(block, table, n) { + const out = new Uint8Array(Math.ceil(table.length / 8)); + for (let i = 0; i < table.length; i++) { + const srcBit = table[i] - 1; + const srcByte = Math.floor(srcBit / 8); + const srcOffset = 7 - (srcBit % 8); + if (block[srcByte] & (1 << srcOffset)) { + const dstByte = Math.floor(i / 8); + const dstOffset = 7 - (i % 8); + out[dstByte] |= (1 << dstOffset); + } + } + return out; + } + + function desGenerateSubkeys(key8) { + const pc1 = permute(key8, PC1); + let C = 0, D = 0; + for (let i = 0; i < 28; i++) { + const byte = Math.floor(i / 8); + const bit = 7 - (i % 8); + if (pc1[byte] & (1 << bit)) C |= (1 << (27 - i)); + } + for (let i = 0; i < 28; i++) { + const byte = Math.floor((i + 28) / 8); + const bit = 7 - ((i + 28) % 8); + if (pc1[byte] & (1 << bit)) D |= (1 << (27 - i)); + } + + const subkeys = []; + for (let round = 0; round < 16; round++) { + const shift = SHIFTS[round]; + C = ((C << shift) | (C >>> (28 - shift))) & 0x0FFFFFFF; + D = ((D << shift) | (D >>> (28 - shift))) & 0x0FFFFFFF; + + const cd = new Uint8Array(7); + for (let i = 0; i < 28; i++) { + if (C & (1 << (27 - i))) { + const byte = Math.floor(i / 8); + cd[byte] |= (1 << (7 - (i % 8))); + } + } + for (let i = 0; i < 28; i++) { + if (D & (1 << (27 - i))) { + const bit = i + 28; + const byte = Math.floor(bit / 8); + cd[byte] |= (1 << (7 - (bit % 8))); + } + } + subkeys.push(permute(cd, PC2)); + } + return subkeys; + } + + function desProcessBlock(block8, subkeys, decrypt) { + const ip = permute(block8, IP); + let L = 0, R = 0; + for (let i = 0; i < 32; i++) { + const byte = Math.floor(i / 8); + const bit = 7 - (i % 8); + if (ip[byte] & (1 << bit)) L |= (1 << (31 - i)); + } + for (let i = 0; i < 32; i++) { + const byte = Math.floor((i + 32) / 8); + const bit = 7 - ((i + 32) % 8); + if (ip[byte] & (1 << bit)) R |= (1 << (31 - i)); + } + + for (let round = 0; round < 16; round++) { + const sk = decrypt ? subkeys[15 - round] : subkeys[round]; + // Expand R + const rBytes = new Uint8Array(4); + rBytes[0] = (R >>> 24) & 0xff; rBytes[1] = (R >>> 16) & 0xff; + rBytes[2] = (R >>> 8) & 0xff; rBytes[3] = R & 0xff; + const expanded = permute(rBytes, E); + + // XOR with subkey + for (let i = 0; i < 6; i++) expanded[i] ^= sk[i]; + + // S-box substitution + let sOut = 0; + for (let i = 0; i < 8; i++) { + const offset = i * 6; + let bits6 = 0; + for (let b = 0; b < 6; b++) { + const byteIdx = Math.floor((offset + b) / 8); + const bitIdx = 7 - ((offset + b) % 8); + if (expanded[byteIdx] & (1 << bitIdx)) bits6 |= (1 << (5 - b)); + } + const row = ((bits6 >> 5) << 1) | (bits6 & 1); + const col = (bits6 >> 1) & 0xf; + sOut = (sOut << 4) | S[i][row * 16 + col]; + } + + // P permutation + const sBytes = new Uint8Array(4); + sBytes[0] = (sOut >>> 24) & 0xff; sBytes[1] = (sOut >>> 16) & 0xff; + sBytes[2] = (sOut >>> 8) & 0xff; sBytes[3] = sOut & 0xff; + const pOut = permute(sBytes, P); + let f = 0; + for (let i = 0; i < 4; i++) f = (f << 8) | pOut[i]; + + const newR = (L ^ f) >>> 0; + L = R; + R = newR; + } + + // Combine R||L (swapped) and final permutation + const rl = new Uint8Array(8); + rl[0] = (R >>> 24) & 0xff; rl[1] = (R >>> 16) & 0xff; + rl[2] = (R >>> 8) & 0xff; rl[3] = R & 0xff; + rl[4] = (L >>> 24) & 0xff; rl[5] = (L >>> 16) & 0xff; + rl[6] = (L >>> 8) & 0xff; rl[7] = L & 0xff; + return permute(rl, FP); + } + + function tripleDES_CBC_decrypt(data, key24, iv8) { + const k1 = key24.slice(0, 8), k2 = key24.slice(8, 16), k3 = key24.slice(16, 24); + const sk1 = desGenerateSubkeys(k1); + const sk2 = desGenerateSubkeys(k2); + const sk3 = desGenerateSubkeys(k3); + const out = new Uint8Array(data.length); + let prevCipher = iv8.slice(); + + for (let i = 0; i < data.length; i += 8) { + const block = data.slice(i, i + 8); + // 3DES decrypt: D_k1(E_k2(D_k3(block))) + let tmp = desProcessBlock(block, sk3, true); + tmp = desProcessBlock(tmp, sk2, false); + tmp = desProcessBlock(tmp, sk1, true); + // XOR with previous ciphertext (CBC) + for (let j = 0; j < 8; j++) out[i + j] = tmp[j] ^ prevCipher[j]; + prevCipher = block; + } + + // Remove PKCS#5/PKCS#7 padding + const padLen = out[out.length - 1]; + if (padLen > 0 && padLen <= 8) { + return out.slice(0, out.length - padLen); + } + return out; + } + + // Parse PKCS#12 / PFX + function parsePKCS12(buf, password) { + const u8 = new Uint8Array(buf); + const root = PEM.parseDER(u8, 0, u8.length); + if (!root.length || !root[0].children) throw new Error('Invalid PKCS#12 structure.'); + + const pfx = root[0]; + if (!pfx.children || pfx.children.length < 2) throw new Error('Invalid PFX structure.'); + + // version should be 3 + const authSafeContent = pfx.children[1]; // ContentInfo + if (!authSafeContent.children || authSafeContent.children.length < 2) + throw new Error('Missing AuthenticatedSafe.'); + + // Get the authSafe data (may be wrapped in [0] EXPLICIT) + let authSafeData; + const contentNode = authSafeContent.children[1]; + if (contentNode.tag === 0xa0 || (contentNode.tag & 0x80)) { + // EXPLICIT wrapper + const inner = PEM.parseDER(contentNode.bytes, 0, contentNode.bytes.length); + if (inner.length && inner[0].tag === 0x04) { + authSafeData = inner[0].bytes; + } else if (inner.length) { + authSafeData = inner[0].bytes; + } + } else if (contentNode.tag === 0x04) { + authSafeData = contentNode.bytes; + } + if (!authSafeData) throw new Error('Could not read AuthenticatedSafe data.'); + + // AuthenticatedSafe = SEQUENCE of ContentInfo + const authSafe = PEM.parseDER(authSafeData, 0, authSafeData.length); + if (!authSafe.length || !authSafe[0].children) throw new Error('Invalid AuthenticatedSafe.'); + + const entries = []; + + for (const contentInfo of authSafe[0].children) { + if (!contentInfo.children || contentInfo.children.length < 2) continue; + const oid = PEM.readOID(contentInfo.children[0].bytes); + + let safeBagsBytes; + + if (oid === '1.2.840.113549.1.7.1') { + // pkcs7-data (unencrypted) — EXPLICIT [0] OCTET STRING + const wrapper = contentInfo.children[1]; + const inner = PEM.parseDER(wrapper.bytes, 0, wrapper.bytes.length); + if (inner.length && inner[0].tag === 0x04) safeBagsBytes = inner[0].bytes; + + } else if (oid === '1.2.840.113549.1.7.6') { + // pkcs7-encryptedData + if (!password) throw new Error('PKCS#12 file is encrypted. Please provide a password.'); + const wrapper = contentInfo.children[1]; + const inner = PEM.parseDER(wrapper.bytes, 0, wrapper.bytes.length); + if (!inner.length || !inner[0].children) continue; + const encData = inner[0]; // EncryptedData + // version + EncryptedContentInfo + const encContentInfo = encData.children[1]; + if (!encContentInfo.children || encContentInfo.children.length < 3) continue; + + // contentEncryptionAlgorithm + const algoSeq = encContentInfo.children[1]; + if (!algoSeq.children || algoSeq.children.length < 2) continue; + const algoOid = PEM.readOID(algoSeq.children[0].bytes); + + // Parse PBE parameters + const pbeParams = algoSeq.children[1]; + if (!pbeParams.children || pbeParams.children.length < 2) continue; + const salt = pbeParams.children[0].bytes; + let iterations = 0; + const iterBytes = pbeParams.children[1].bytes; + for (let i = 0; i < iterBytes.length; i++) iterations = iterations * 256 + iterBytes[i]; + + // Get encrypted content (may be IMPLICIT [0]) + const encContentNode = encContentInfo.children[2]; + const encBytes = encContentNode.bytes; + + // Derive key and IV using PKCS#12 KDF + let decrypted; + if (algoOid === '1.2.840.113549.1.12.1.3' || algoOid === '1.2.840.113549.1.12.1.6') { + // pbeWithSHA1And3-KeyTripleDES-CBC or pbeWithSHA1And40BitRC2-CBC + // For 3DES: 24-byte key, 8-byte IV + const keyLen = algoOid.endsWith('.3') ? 24 : 5; + const key = pkcs12KDF(password, salt, iterations, 1, keyLen); + const iv = pkcs12KDF(password, salt, iterations, 2, 8); + + if (keyLen === 24) { + decrypted = tripleDES_CBC_decrypt(encBytes, key, iv); + } else { + // RC2 not implemented — skip this container + continue; + } + } else { + continue; // Unknown algorithm + } + + safeBagsBytes = decrypted; + } + + if (!safeBagsBytes) continue; + + // Parse SafeContents = SEQUENCE of SafeBag + const safeBags = PEM.parseDER(safeBagsBytes, 0, safeBagsBytes.length); + if (!safeBags.length || !safeBags[0].children) continue; + + for (const safeBag of safeBags[0].children) { + if (!safeBag.children || safeBag.children.length < 2) continue; + const bagOid = PEM.readOID(safeBag.children[0].bytes); + + // Extract friendly name from bag attributes if present + let alias = ''; + if (safeBag.children.length > 2) { + const attrs = safeBag.children[2]; + const attrNodes = (attrs.tag & 0x20) ? (attrs.children || PEM.parseDER(attrs.bytes, 0, attrs.bytes.length)) : PEM.parseDER(attrs.bytes, 0, attrs.bytes.length); + for (const attr of attrNodes) { + if (!attr.children || attr.children.length < 2) continue; + const attrOid = PEM.readOID(attr.children[0].bytes); + if (attrOid === '1.2.840.113549.1.9.20') { + // friendlyName (BMPString) + const valSet = attr.children[1]; + const valNode = valSet.children ? valSet.children[0] : valSet; + const bmpBytes = valNode.bytes; + let name = ''; + for (let i = 0; i < bmpBytes.length; i += 2) { + name += String.fromCharCode((bmpBytes[i] << 8) | bmpBytes[i + 1]); + } + alias = name; + } + } + } + + if (bagOid === '1.2.840.113549.1.12.10.1.1') { + // keyBag or pkcs8ShroudedKeyBag — private key, just note it + entries.push({ alias: alias || 'Private Key', type: 'Private Key', timestamp: null, derCerts: [] }); + } else if (bagOid === '1.2.840.113549.1.12.10.1.2') { + // pkcs8ShroudedKeyBag + entries.push({ alias: alias || 'Private Key', type: 'Private Key (Encrypted)', timestamp: null, derCerts: [] }); + } else if (bagOid === '1.2.840.113549.1.12.10.1.3') { + // certBag + const bagValue = safeBag.children[1]; + const inner = PEM.parseDER(bagValue.bytes, 0, bagValue.bytes.length); + if (inner.length && inner[0].children && inner[0].children.length >= 2) { + const certContent = inner[0].children[1]; + const certInner = PEM.parseDER(certContent.bytes, 0, certContent.bytes.length); + if (certInner.length && certInner[0].tag === 0x04) { + entries.push({ + alias: alias || 'Certificate', + type: 'Certificate', + timestamp: null, + derCerts: [new Uint8Array(certInner[0].bytes)], + }); + } + } + } + } + } + return entries; + } + + // ── PEM bundle parser ── + function parsePEMBundle(text) { + const blocks = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!blocks || !blocks.length) throw new Error('No PEM certificates found.'); + return blocks.map((block, i) => { + const b64 = block.replace(/-----BEGIN CERTIFICATE-----/, '').replace(/-----END CERTIFICATE-----/, '').replace(/\s/g, ''); + const raw = atob(b64); + const der = new Uint8Array(raw.length); + for (let j = 0; j < raw.length; j++) der[j] = raw.charCodeAt(j); + return { alias: 'Certificate ' + (i + 1), type: 'Certificate', timestamp: null, derCerts: [der] }; + }); + } + + return { detectFormat, parseJKS, parsePKCS12, parsePEMBundle }; +})(); + +async function keystoreDecode() { + const fileInput = document.getElementById('ks-file'); + const passInput = document.getElementById('ks-pass'); + const resultsEl = document.getElementById('ks-results'); + const summaryEl = document.getElementById('ks-summary'); + setError('ks-err', ''); + resultsEl.innerHTML = ''; + resultsEl.style.display = 'none'; + summaryEl.style.display = 'none'; + + if (!fileInput.files.length) { setError('ks-err', 'Please select a keystore file.'); return; } + + const file = fileInput.files[0]; + const buf = await file.arrayBuffer(); + const format = KEYSTORE.detectFormat(buf); + + if (format === 'unknown') { setError('ks-err', 'Unrecognized file format. Supported: JKS, PKCS#12/PFX, PEM.'); return; } + + let entries; + try { + if (format === 'jks') { + entries = KEYSTORE.parseJKS(buf); + } else if (format === 'p12') { + entries = KEYSTORE.parsePKCS12(buf, passInput.value); + } else { + const text = new TextDecoder().decode(new Uint8Array(buf)); + entries = KEYSTORE.parsePEMBundle(text); + } + } catch (e) { + setError('ks-err', e.message); return; + } + + if (!entries.length) { setError('ks-err', 'No entries found in the keystore.'); return; } + + const certCount = entries.filter(e => e.derCerts.length > 0).reduce((s, e) => s + e.derCerts.length, 0); + const keyCount = entries.filter(e => e.type.includes('Private Key')).length; + summaryEl.innerHTML = `${entries.length} entries · ${certCount} certificate${certCount !== 1 ? 's' : ''}${keyCount ? ` · ${keyCount} private key${keyCount !== 1 ? 's' : ''}` : ''} · Format: ${format.toUpperCase()}`; + summaryEl.style.display = 'block'; + + const esc = s => String(s).replace(/&/g, '&').replace(//g, '>'); + const nameFieldLabels = { + CN: 'Common Name', O: 'Organization', OU: 'Org Unit', + L: 'Locality', ST: 'State', C: 'Country', + emailAddress: 'Email', DC: 'Domain Component', + }; + const nameFieldOrder = ['CN', 'O', 'OU', 'L', 'ST', 'C', 'emailAddress', 'DC']; + + function renderNameFields(nameObj) { + const f = nameObj.fields; + const allKeys = nameFieldOrder.filter(k => f[k]).concat(Object.keys(f).filter(k => !nameFieldOrder.includes(k))); + return allKeys.map(k => { + const label = nameFieldLabels[k] || k; + return `
${esc(label)}${esc(f[k])}
`; + }).join(''); + } + + let html = ''; + for (let ei = 0; ei < entries.length; ei++) { + const entry = entries[ei]; + const typeBadge = entry.type.includes('Private Key') + ? 'Private Key' + : 'Certificate'; + + html += `
+
+ ${typeBadge} + ${esc(entry.alias)} + ${entry.timestamp ? `${entry.timestamp.toLocaleDateString()}` : ''} +
`; + + if (entry.derCerts.length > 0) { + const certs = await PEM.decodeDER(entry.derCerts); + for (let ci = 0; ci < certs.length; ci++) { + const cert = certs[ci]; + const statusCls = cert.isExpired ? 'pem-expired' : cert.isNotYetValid ? 'pem-expired' : 'pem-valid'; + const statusMsg = cert.isExpired ? 'EXPIRED' : cert.isNotYetValid ? 'NOT YET VALID' : 'VALID'; + const chainLabel = entry.derCerts.length > 1 ? `
Chain Certificate ${ci + 1} of ${entry.derCerts.length}
` : ''; + + html += `${chainLabel}
+
${statusMsg}
+
Subject
${renderNameFields(cert.subject)}
+ ${cert.sans.length ? `
SANs (${cert.sans.length})
${cert.sans.map(s => `${esc(s)}`).join('')}
` : ''} +
Issuer
${renderNameFields(cert.issuer)}
+
Validity
+
Valid From${cert.notBefore ? cert.notBefore.toUTCString() : 'N/A'}
+
Valid To${cert.notAfter ? cert.notAfter.toUTCString() : 'N/A'}
+
+
Details
+
VersionV${cert.version}
+
Serial${esc(String(cert.serial))}
+
Sig Algo${esc(cert.sigAlgo)}
+
Public Key${esc(cert.pubKeyInfo.algorithm)} (${cert.pubKeyInfo.bits} bit)
+ ${cert.basicConstraints ? `
Basic Constraints${esc(cert.basicConstraints)}
` : ''} + ${cert.keyUsage.length ? `
Key Usage${cert.keyUsage.map(esc).join(', ')}
` : ''} + ${cert.extKeyUsage.length ? `
Ext Key Usage${cert.extKeyUsage.map(esc).join(', ')}
` : ''} +
+
Fingerprints
+
SHA-1${cert.sha1} +
+
SHA-256${cert.sha256} +
+
+
`; + } + } else { + html += '
Private key data (encrypted) — certificate not available in this entry.
'; + } + + html += '
'; + } + + resultsEl.innerHTML = html; + resultsEl.style.display = 'flex'; +} + +function clearKeystore() { + document.getElementById('ks-file').value = ''; + document.getElementById('ks-pass').value = ''; + setError('ks-err', ''); + const r = document.getElementById('ks-results'); + r.style.display = 'none'; r.innerHTML = ''; + document.getElementById('ks-summary').style.display = 'none'; +} + + /* ── Theme toggle ── */ function toggleTheme() { const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; @@ -867,5 +3156,761 @@ document.getElementById('b64urlenc-in').addEventListener('input', b64urlEncode); document.getElementById('b64urldec-in').addEventListener('input', b64urlDecode); document.getElementById('regex-pattern').addEventListener('input', regexTest); document.getElementById('regex-test').addEventListener('input', regexTest); +document.getElementById('pem-in').addEventListener('input', pemDecode); +document.getElementById('csrdec-in').addEventListener('input', csrDec); // Init HTTP table on load renderHttpTable(''); + +/* ── Time Zone Converter (WorldTimeBuddy-style) ── */ + +// World capitals timezone catalogue +const TZ_CATALOG = [ + { name: 'Kabul', country: 'Afghanistan', abbr: 'AFT', offset: 4.5 }, + { name: 'Tirana', country: 'Albania', abbr: 'CET', offset: 1 }, + { name: 'Algiers', country: 'Algeria', abbr: 'CET', offset: 1 }, + { name: 'Andorra la Vella', country: 'Andorra', abbr: 'CET', offset: 1 }, + { name: 'Luanda', country: 'Angola', abbr: 'WAT', offset: 1 }, + { name: "St. John's", country: 'Antigua and Barbuda', abbr: 'AST', offset: -4 }, + { name: 'Buenos Aires', country: 'Argentina', abbr: 'ART', offset: -3 }, + { name: 'Yerevan', country: 'Armenia', abbr: 'AMT', offset: 4 }, + { name: 'Canberra', country: 'Australia', abbr: 'AEST', offset: 10 }, + { name: 'Vienna', country: 'Austria', abbr: 'CET', offset: 1 }, + { name: 'Baku', country: 'Azerbaijan', abbr: 'AZT', offset: 4 }, + { name: 'Nassau', country: 'Bahamas', abbr: 'EST', offset: -5 }, + { name: 'Manama', country: 'Bahrain', abbr: 'AST', offset: 3 }, + { name: 'Dhaka', country: 'Bangladesh', abbr: 'BST', offset: 6 }, + { name: 'Bridgetown', country: 'Barbados', abbr: 'AST', offset: -4 }, + { name: 'Minsk', country: 'Belarus', abbr: 'MSK', offset: 3 }, + { name: 'Brussels', country: 'Belgium', abbr: 'CET', offset: 1 }, + { name: 'Belmopan', country: 'Belize', abbr: 'CST', offset: -6 }, + { name: 'Porto-Novo', country: 'Benin', abbr: 'WAT', offset: 1 }, + { name: 'Thimphu', country: 'Bhutan', abbr: 'BTT', offset: 6 }, + { name: 'Sucre', country: 'Bolivia', abbr: 'BOT', offset: -4 }, + { name: 'Sarajevo', country: 'Bosnia and Herzegovina', abbr: 'CET', offset: 1 }, + { name: 'Gaborone', country: 'Botswana', abbr: 'CAT', offset: 2 }, + { name: 'Brasília', country: 'Brazil', abbr: 'BRT', offset: -3 }, + { name: 'Bandar Seri Begawan', country: 'Brunei', abbr: 'BNT', offset: 8 }, + { name: 'Sofia', country: 'Bulgaria', abbr: 'EET', offset: 2 }, + { name: 'Ouagadougou', country: 'Burkina Faso', abbr: 'GMT', offset: 0 }, + { name: 'Gitega', country: 'Burundi', abbr: 'CAT', offset: 2 }, + { name: 'Praia', country: 'Cabo Verde', abbr: 'CVT', offset: -1 }, + { name: 'Phnom Penh', country: 'Cambodia', abbr: 'ICT', offset: 7 }, + { name: 'Yaoundé', country: 'Cameroon', abbr: 'WAT', offset: 1 }, + { name: 'Ottawa', country: 'Canada', abbr: 'EST', offset: -5 }, + { name: 'Bangui', country: 'Central African Republic', abbr: 'WAT', offset: 1 }, + { name: "N'Djamena", country: 'Chad', abbr: 'WAT', offset: 1 }, + { name: 'Santiago', country: 'Chile', abbr: 'CLT', offset: -4 }, + { name: 'Beijing', country: 'China', abbr: 'CST', offset: 8 }, + { name: 'Bogotá', country: 'Colombia', abbr: 'COT', offset: -5 }, + { name: 'Moroni', country: 'Comoros', abbr: 'EAT', offset: 3 }, + { name: 'Brazzaville', country: 'Congo (Republic)', abbr: 'WAT', offset: 1 }, + { name: 'Kinshasa', country: 'Congo (Democratic Republic)', abbr: 'WAT', offset: 1 }, + { name: 'San José', country: 'Costa Rica', abbr: 'CST', offset: -6 }, + { name: 'Zagreb', country: 'Croatia', abbr: 'CET', offset: 1 }, + { name: 'Havana', country: 'Cuba', abbr: 'CST', offset: -5 }, + { name: 'Nicosia', country: 'Cyprus', abbr: 'EET', offset: 2 }, + { name: 'Prague', country: 'Czechia', abbr: 'CET', offset: 1 }, + { name: 'Yamoussoukro', country: "Côte d'Ivoire", abbr: 'GMT', offset: 0 }, + { name: 'Copenhagen', country: 'Denmark', abbr: 'CET', offset: 1 }, + { name: 'Djibouti', country: 'Djibouti', abbr: 'EAT', offset: 3 }, + { name: 'Roseau', country: 'Dominica', abbr: 'AST', offset: -4 }, + { name: 'Santo Domingo', country: 'Dominican Republic', abbr: 'AST', offset: -4 }, + { name: 'Quito', country: 'Ecuador', abbr: 'ECT', offset: -5 }, + { name: 'Cairo', country: 'Egypt', abbr: 'EET', offset: 2 }, + { name: 'San Salvador', country: 'El Salvador', abbr: 'CST', offset: -6 }, + { name: 'Malabo', country: 'Equatorial Guinea', abbr: 'WAT', offset: 1 }, + { name: 'Asmara', country: 'Eritrea', abbr: 'EAT', offset: 3 }, + { name: 'Tallinn', country: 'Estonia', abbr: 'EET', offset: 2 }, + { name: 'Mbabane', country: 'Eswatini', abbr: 'SAST', offset: 2 }, + { name: 'Addis Ababa', country: 'Ethiopia', abbr: 'EAT', offset: 3 }, + { name: 'Suva', country: 'Fiji', abbr: 'FJT', offset: 12 }, + { name: 'Helsinki', country: 'Finland', abbr: 'EET', offset: 2 }, + { name: 'Paris', country: 'France', abbr: 'CET', offset: 1 }, + { name: 'Libreville', country: 'Gabon', abbr: 'WAT', offset: 1 }, + { name: 'Banjul', country: 'Gambia', abbr: 'GMT', offset: 0 }, + { name: 'Tbilisi', country: 'Georgia', abbr: 'GET', offset: 4 }, + { name: 'Berlin', country: 'Germany', abbr: 'CET', offset: 1 }, + { name: 'Accra', country: 'Ghana', abbr: 'GMT', offset: 0 }, + { name: 'Athens', country: 'Greece', abbr: 'EET', offset: 2 }, + { name: "St. George's", country: 'Grenada', abbr: 'AST', offset: -4 }, + { name: 'Guatemala City', country: 'Guatemala', abbr: 'CST', offset: -6 }, + { name: 'Conakry', country: 'Guinea', abbr: 'GMT', offset: 0 }, + { name: 'Bissau', country: 'Guinea-Bissau', abbr: 'GMT', offset: 0 }, + { name: 'Georgetown', country: 'Guyana', abbr: 'GYT', offset: -4 }, + { name: 'Port-au-Prince', country: 'Haiti', abbr: 'EST', offset: -5 }, + { name: 'Vatican City', country: 'Holy See', abbr: 'CET', offset: 1 }, + { name: 'Tegucigalpa', country: 'Honduras', abbr: 'CST', offset: -6 }, + { name: 'Budapest', country: 'Hungary', abbr: 'CET', offset: 1 }, + { name: 'Reykjavik', country: 'Iceland', abbr: 'GMT', offset: 0 }, + { name: 'New Delhi', country: 'India', abbr: 'IST', offset: 5.5 }, + { name: 'Jakarta', country: 'Indonesia', abbr: 'WIB', offset: 7 }, + { name: 'Tehran', country: 'Iran', abbr: 'IRST', offset: 3.5 }, + { name: 'Baghdad', country: 'Iraq', abbr: 'AST', offset: 3 }, + { name: 'Dublin', country: 'Ireland', abbr: 'GMT', offset: 0 }, + { name: 'Jerusalem', country: 'Israel', abbr: 'IST', offset: 2 }, + { name: 'Rome', country: 'Italy', abbr: 'CET', offset: 1 }, + { name: 'Kingston', country: 'Jamaica', abbr: 'EST', offset: -5 }, + { name: 'Tokyo', country: 'Japan', abbr: 'JST', offset: 9 }, + { name: 'Amman', country: 'Jordan', abbr: 'EET', offset: 2 }, + { name: 'Astana', country: 'Kazakhstan', abbr: 'UTC+5', offset: 5 }, + { name: 'Nairobi', country: 'Kenya', abbr: 'EAT', offset: 3 }, + { name: 'Tarawa', country: 'Kiribati', abbr: 'GILT', offset: 12 }, + { name: 'Pyongyang', country: 'Korea (North)', abbr: 'KST', offset: 9 }, + { name: 'Seoul', country: 'Korea (South)', abbr: 'KST', offset: 9 }, + { name: 'Kuwait City', country: 'Kuwait', abbr: 'AST', offset: 3 }, + { name: 'Bishkek', country: 'Kyrgyzstan', abbr: 'KGT', offset: 6 }, + { name: 'Vientiane', country: 'Laos', abbr: 'ICT', offset: 7 }, + { name: 'Riga', country: 'Latvia', abbr: 'EET', offset: 2 }, + { name: 'Beirut', country: 'Lebanon', abbr: 'EET', offset: 2 }, + { name: 'Maseru', country: 'Lesotho', abbr: 'SAST', offset: 2 }, + { name: 'Monrovia', country: 'Liberia', abbr: 'GMT', offset: 0 }, + { name: 'Tripoli', country: 'Libya', abbr: 'EET', offset: 2 }, + { name: 'Vaduz', country: 'Liechtenstein', abbr: 'CET', offset: 1 }, + { name: 'Vilnius', country: 'Lithuania', abbr: 'EET', offset: 2 }, + { name: 'Luxembourg', country: 'Luxembourg', abbr: 'CET', offset: 1 }, + { name: 'Antananarivo', country: 'Madagascar', abbr: 'EAT', offset: 3 }, + { name: 'Lilongwe', country: 'Malawi', abbr: 'CAT', offset: 2 }, + { name: 'Kuala Lumpur', country: 'Malaysia', abbr: 'MYT', offset: 8 }, + { name: 'Malé', country: 'Maldives', abbr: 'MVT', offset: 5 }, + { name: 'Bamako', country: 'Mali', abbr: 'GMT', offset: 0 }, + { name: 'Valletta', country: 'Malta', abbr: 'CET', offset: 1 }, + { name: 'Majuro', country: 'Marshall Islands', abbr: 'MHT', offset: 12 }, + { name: 'Nouakchott', country: 'Mauritania', abbr: 'GMT', offset: 0 }, + { name: 'Port Louis', country: 'Mauritius', abbr: 'MUT', offset: 4 }, + { name: 'Mexico City', country: 'Mexico', abbr: 'CST', offset: -6 }, + { name: 'Palikir', country: 'Micronesia', abbr: 'PONT', offset: 11 }, + { name: 'Chișinău', country: 'Moldova', abbr: 'EET', offset: 2 }, + { name: 'Monaco', country: 'Monaco', abbr: 'CET', offset: 1 }, + { name: 'Ulaanbaatar', country: 'Mongolia', abbr: 'ULAT', offset: 8 }, + { name: 'Podgorica', country: 'Montenegro', abbr: 'CET', offset: 1 }, + { name: 'Rabat', country: 'Morocco', abbr: 'UTC+1', offset: 1 }, + { name: 'Maputo', country: 'Mozambique', abbr: 'CAT', offset: 2 }, + { name: 'Naypyidaw', country: 'Myanmar', abbr: 'MMT', offset: 6.5 }, + { name: 'Windhoek', country: 'Namibia', abbr: 'CAT', offset: 2 }, + { name: 'Yaren', country: 'Nauru', abbr: 'NRT', offset: 12 }, + { name: 'Kathmandu', country: 'Nepal', abbr: 'NPT', offset: 5.75 }, + { name: 'Amsterdam', country: 'Netherlands', abbr: 'CET', offset: 1 }, + { name: 'Wellington', country: 'New Zealand', abbr: 'NZST', offset: 12 }, + { name: 'Managua', country: 'Nicaragua', abbr: 'CST', offset: -6 }, + { name: 'Niamey', country: 'Niger', abbr: 'WAT', offset: 1 }, + { name: 'Abuja', country: 'Nigeria', abbr: 'WAT', offset: 1 }, + { name: 'Skopje', country: 'North Macedonia', abbr: 'CET', offset: 1 }, + { name: 'Oslo', country: 'Norway', abbr: 'CET', offset: 1 }, + { name: 'Muscat', country: 'Oman', abbr: 'GST', offset: 4 }, + { name: 'Islamabad', country: 'Pakistan', abbr: 'PKT', offset: 5 }, + { name: 'Ngerulmud', country: 'Palau', abbr: 'PWT', offset: 9 }, + { name: 'Ramallah', country: 'Palestine', abbr: 'EET', offset: 2 }, + { name: 'Panama City', country: 'Panama', abbr: 'EST', offset: -5 }, + { name: 'Port Moresby', country: 'Papua New Guinea', abbr: 'PGT', offset: 10 }, + { name: 'Asunción', country: 'Paraguay', abbr: 'PYT', offset: -4 }, + { name: 'Lima', country: 'Peru', abbr: 'PET', offset: -5 }, + { name: 'Manila', country: 'Philippines', abbr: 'PHT', offset: 8 }, + { name: 'Warsaw', country: 'Poland', abbr: 'CET', offset: 1 }, + { name: 'Lisbon', country: 'Portugal', abbr: 'WET', offset: 0 }, + { name: 'Doha', country: 'Qatar', abbr: 'AST', offset: 3 }, + { name: 'Bucharest', country: 'Romania', abbr: 'EET', offset: 2 }, + { name: 'Moscow', country: 'Russia', abbr: 'MSK', offset: 3 }, + { name: 'Kigali', country: 'Rwanda', abbr: 'CAT', offset: 2 }, + { name: 'Basseterre', country: 'Saint Kitts and Nevis', abbr: 'AST', offset: -4 }, + { name: 'Castries', country: 'Saint Lucia', abbr: 'AST', offset: -4 }, + { name: 'Kingstown', country: 'Saint Vincent and the Grenadines', abbr: 'AST', offset: -4 }, + { name: 'Apia', country: 'Samoa', abbr: 'WST', offset: 13 }, + { name: 'San Marino', country: 'San Marino', abbr: 'CET', offset: 1 }, + { name: 'São Tomé', country: 'São Tomé and Príncipe', abbr: 'GMT', offset: 0 }, + { name: 'Riyadh', country: 'Saudi Arabia', abbr: 'AST', offset: 3 }, + { name: 'Dakar', country: 'Senegal', abbr: 'GMT', offset: 0 }, + { name: 'Belgrade', country: 'Serbia', abbr: 'CET', offset: 1 }, + { name: 'Victoria', country: 'Seychelles', abbr: 'SCT', offset: 4 }, + { name: 'Freetown', country: 'Sierra Leone', abbr: 'GMT', offset: 0 }, + { name: 'Singapore', country: 'Singapore', abbr: 'SGT', offset: 8 }, + { name: 'Bratislava', country: 'Slovakia', abbr: 'CET', offset: 1 }, + { name: 'Ljubljana', country: 'Slovenia', abbr: 'CET', offset: 1 }, + { name: 'Honiara', country: 'Solomon Islands', abbr: 'SBT', offset: 11 }, + { name: 'Mogadishu', country: 'Somalia', abbr: 'EAT', offset: 3 }, + { name: 'Pretoria', country: 'South Africa', abbr: 'SAST', offset: 2 }, + { name: 'Juba', country: 'South Sudan', abbr: 'CAT', offset: 2 }, + { name: 'Madrid', country: 'Spain', abbr: 'CET', offset: 1 }, + { name: 'Sri Jayawardenepura Kotte', country: 'Sri Lanka', abbr: 'IST', offset: 5.5 }, + { name: 'Khartoum', country: 'Sudan', abbr: 'CAT', offset: 2 }, + { name: 'Paramaribo', country: 'Suriname', abbr: 'SRT', offset: -3 }, + { name: 'Stockholm', country: 'Sweden', abbr: 'CET', offset: 1 }, + { name: 'Bern', country: 'Switzerland', abbr: 'CET', offset: 1 }, + { name: 'Damascus', country: 'Syria', abbr: 'EET', offset: 2 }, + { name: 'Taipei', country: 'Taiwan', abbr: 'CST', offset: 8 }, + { name: 'Dushanbe', country: 'Tajikistan', abbr: 'TJT', offset: 5 }, + { name: 'Dodoma', country: 'Tanzania', abbr: 'EAT', offset: 3 }, + { name: 'Bangkok', country: 'Thailand', abbr: 'ICT', offset: 7 }, + { name: 'Dili', country: 'Timor-Leste', abbr: 'TLT', offset: 9 }, + { name: 'Lomé', country: 'Togo', abbr: 'GMT', offset: 0 }, + { name: "Nuku'alofa", country: 'Tonga', abbr: 'TOT', offset: 13 }, + { name: 'Port of Spain', country: 'Trinidad and Tobago', abbr: 'AST', offset: -4 }, + { name: 'Tunis', country: 'Tunisia', abbr: 'CET', offset: 1 }, + { name: 'Ankara', country: 'Turkey', abbr: 'TRT', offset: 3 }, + { name: 'Ashgabat', country: 'Turkmenistan', abbr: 'TMT', offset: 5 }, + { name: 'Funafuti', country: 'Tuvalu', abbr: 'TVT', offset: 12 }, + { name: 'Kampala', country: 'Uganda', abbr: 'EAT', offset: 3 }, + { name: 'Kyiv', country: 'Ukraine', abbr: 'EET', offset: 2 }, + { name: 'Abu Dhabi', country: 'United Arab Emirates', abbr: 'GST', offset: 4 }, + { name: 'London', country: 'United Kingdom', abbr: 'GMT', offset: 0 }, + { name: 'Washington, D.C.', country: 'United States', abbr: 'EST', offset: -5 }, + { name: 'Montevideo', country: 'Uruguay', abbr: 'UYT', offset: -3 }, + { name: 'Tashkent', country: 'Uzbekistan', abbr: 'UZT', offset: 5 }, + { name: 'Port Vila', country: 'Vanuatu', abbr: 'VUT', offset: 11 }, + { name: 'Caracas', country: 'Venezuela', abbr: 'VET', offset: -4 }, + { name: 'Hanoi', country: 'Vietnam', abbr: 'ICT', offset: 7 }, + { name: "Sana'a", country: 'Yemen', abbr: 'AST', offset: 3 }, + { name: 'Lusaka', country: 'Zambia', abbr: 'CAT', offset: 2 }, + { name: 'Harare', country: 'Zimbabwe', abbr: 'CAT', offset: 2 }, + // Major named timezones (searchable by common names) + { name: 'Pacific Time', country: 'US West Coast', abbr: 'PST', offset: -8 }, + { name: 'Mountain Time', country: 'US Mountain', abbr: 'MST', offset: -7 }, + { name: 'Central Time', country: 'US Central', abbr: 'CST', offset: -6 }, + { name: 'Eastern Time', country: 'US East Coast', abbr: 'EST', offset: -5 }, + { name: 'Alaska Time', country: 'US Alaska', abbr: 'AKST', offset: -9 }, + { name: 'Hawaii Time', country: 'US Hawaii', abbr: 'HST', offset: -10 }, + { name: 'Atlantic Time', country: 'Caribbean / Atlantic', abbr: 'AST', offset: -4 }, + { name: 'Newfoundland Time', country: 'Canada', abbr: 'NST', offset: -3.5 }, + { name: 'Central European Time', country: 'Europe', abbr: 'CET', offset: 1 }, + { name: 'Eastern European Time', country: 'Europe', abbr: 'EET', offset: 2 }, + { name: 'Western European Time', country: 'Europe', abbr: 'WET', offset: 0 }, + { name: 'Greenwich Mean Time', country: 'Global', abbr: 'GMT', offset: 0 }, + { name: 'Coordinated Universal Time', country: 'Global', abbr: 'UTC', offset: 0 }, + { name: 'Moscow Time', country: 'Russia', abbr: 'MSK', offset: 3 }, + { name: 'Gulf Standard Time', country: 'Persian Gulf', abbr: 'GST', offset: 4 }, + { name: 'India Standard Time', country: 'India', abbr: 'IST', offset: 5.5 }, + { name: 'Indochina Time', country: 'Southeast Asia', abbr: 'ICT', offset: 7 }, + { name: 'China Standard Time', country: 'China / Taiwan', abbr: 'CST', offset: 8 }, + { name: 'Japan Standard Time', country: 'Japan', abbr: 'JST', offset: 9 }, + { name: 'Korea Standard Time', country: 'South Korea', abbr: 'KST', offset: 9 }, + { name: 'Australian Eastern Time', country: 'Australia', abbr: 'AEST', offset: 10 }, + { name: 'Australian Central Time', country: 'Australia', abbr: 'ACST', offset: 9.5 }, + { name: 'Australian Western Time', country: 'Australia', abbr: 'AWST', offset: 8 }, + { name: 'New Zealand Time', country: 'New Zealand', abbr: 'NZST', offset: 12 }, + { name: 'Singapore Time', country: 'Singapore / Malaysia', abbr: 'SGT', offset: 8 }, + { name: 'Hong Kong Time', country: 'Hong Kong', abbr: 'HKT', offset: 8 }, + { name: 'South Africa Standard Time', country: 'South Africa', abbr: 'SAST', offset: 2 }, + { name: 'East Africa Time', country: 'East Africa', abbr: 'EAT', offset: 3 }, + { name: 'West Africa Time', country: 'West Africa', abbr: 'WAT', offset: 1 }, + { name: 'Brasília Time', country: 'Brazil', abbr: 'BRT', offset: -3 }, + { name: 'Argentina Time', country: 'Argentina', abbr: 'ART', offset: -3 }, +]; + +// Active rows shown in the grid +let tzRows = [ + { name: 'New Delhi', country: 'India', abbr: 'IST', offset: 5.5 }, + { name: 'Brasília', country: 'Brazil', abbr: 'BRT', offset: -3 }, + { name: 'London', country: 'United Kingdom', abbr: 'GMT', offset: 0 }, + { name: 'Washington, D.C.', country: 'United States', abbr: 'EST', offset: -5 }, + { name: 'Tokyo', country: 'Japan', abbr: 'JST', offset: 9 }, +]; + +let tzSelectedUTCHour = null; // 0-23, which UTC hour is selected +let tzBaseDate = null; // Date object for the selected date (midnight UTC) + +function formatTzTime(date) { + const h = String(date.getUTCHours()).padStart(2, '0'); + const m = String(date.getUTCMinutes()).padStart(2, '0'); + const s = String(date.getUTCSeconds()).padStart(2, '0'); + return `${h}:${m}:${s}`; +} + +function formatTzDate(date) { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${days[date.getUTCDay()]}, ${date.getUTCDate()} ${months[date.getUTCMonth()]} ${date.getUTCFullYear()}`; +} + +function formatTzDateShort(date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`; +} + +function dateInOffset(utcMs, offsetHours) { + return new Date(utcMs + offsetHours * 3600000); +} + +// Hero clocks — live +function updateTzHero() { + const now = Date.now(); + const ist = dateInOffset(now, 5.5); + const brt = dateInOffset(now, -3); + const heroIst = document.getElementById('tz-hero-ist'); + if (!heroIst) return; + heroIst.textContent = formatTzTime(ist); + document.getElementById('tz-hero-ist-date').textContent = formatTzDate(ist); + document.getElementById('tz-hero-brt').textContent = formatTzTime(brt); + document.getElementById('tz-hero-brt-date').textContent = formatTzDate(brt); +} +updateTzHero(); +setInterval(updateTzHero, 1000); + +// Format hour for cell display +function fmtHour(h) { + const total = (((h % 24) + 24) % 24); // e.g. 0.5, 13.75 + const hr = Math.floor(total); // whole hour: 0, 13 + const frac = total - hr; // fractional part: 0.5, 0.75 + const min = Math.round(frac * 60); // minutes: 30, 45 + const minStr = min > 0 ? ':' + String(min).padStart(2, '0') : ''; + const display = hr === 0 ? 12 : hr > 12 ? hr - 12 : hr; + const ampm = hr < 12 ? 'AM' : 'PM'; + return { top: String(display) + minStr, bot: ampm }; +} + +// Color class for an hour (local hour in that timezone) +function hourClass(h) { + const hr = ((Math.floor(h) % 24) + 24) % 24; + if (hr >= 9 && hr < 18) return 'tz-day'; + if ((hr >= 7 && hr < 9) || (hr >= 18 && hr < 21)) return 'tz-fringe'; + return 'tz-night'; +} + +// Offset label like "+05:30" or "−03:00" +function offsetLabel(offset) { + const sign = offset >= 0 ? '+' : '−'; + const abs = Math.abs(offset); + const h = Math.floor(abs); + const m = Math.round((abs - h) * 60); + return `${sign}${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +// ── Render the grid ── +function tzRenderGrid() { + const grid = document.getElementById('tz-grid'); + if (!grid) return; + grid.innerHTML = ''; + + if (!tzBaseDate) tzSetToday(); + + const baseMidnightUTC = tzBaseDate.getTime(); // midnight UTC of selected date + + tzRows.forEach((tz, rowIdx) => { + const row = document.createElement('div'); + row.className = 'tz-row'; + + // Label + const label = document.createElement('div'); + label.className = 'tz-row-label'; + label.innerHTML = `
${tz.name}
+
${tz.country ? tz.country + ' · ' : ''}${tz.abbr} · UTC ${offsetLabel(tz.offset)}
`; + row.appendChild(label); + + // Remove button (don't allow removing if only 1 row) + if (tzRows.length > 1) { + const rm = document.createElement('button'); + rm.className = 'tz-row-remove'; + rm.textContent = '✕'; + rm.title = 'Remove'; + rm.onclick = () => { tzRows.splice(rowIdx, 1); tzRenderGrid(); }; + row.appendChild(rm); + } + + // Hour strip + const strip = document.createElement('div'); + strip.className = 'tz-hours'; + + for (let utcH = 0; utcH < 24; utcH++) { + const localH = utcH + tz.offset; + const localHNorm = ((Math.floor(localH) % 24) + 24) % 24; + const { top, bot } = fmtHour(localH); + + const cell = document.createElement('div'); + cell.className = `tz-cell ${hourClass(localH)}`; + if (localHNorm === 0) cell.classList.add('tz-midnight'); + if (utcH === tzSelectedUTCHour) cell.classList.add('tz-selected'); + + cell.innerHTML = `${top}${bot}`; + + // Click to select this column across all rows + cell.addEventListener('click', () => { + tzSelectedUTCHour = utcH; + tzRenderGrid(); + }); + + // Drag support + cell.addEventListener('mousedown', (e) => { + e.preventDefault(); + tzSelectedUTCHour = utcH; + tzRenderGrid(); + const onMove = (ev) => { + const target = document.elementFromPoint(ev.clientX, ev.clientY); + if (target && target.closest('.tz-cell')) { + const allCells = [...strip.children]; + const idx = allCells.indexOf(target.closest('.tz-cell')); + if (idx >= 0 && idx !== tzSelectedUTCHour) { + tzSelectedUTCHour = idx; + tzRenderGrid(); + } + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + strip.appendChild(cell); + } + + row.appendChild(strip); + grid.appendChild(row); + }); + + // Scroll all strips to show current selected hour or ~9AM range + setTimeout(() => { + grid.querySelectorAll('.tz-hours').forEach(strip => { + const target = strip.querySelector('.tz-selected') || strip.children[9]; + if (target) target.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }); + }); + }, 0); +} + +// Set date to today +function tzSetToday() { + const now = new Date(); + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + const d = String(now.getUTCDate()).padStart(2, '0'); + document.getElementById('tz-date').value = `${y}-${m}-${d}`; + tzBaseDate = new Date(`${y}-${m}-${d}T00:00:00Z`); + // Set selected hour to current UTC hour + tzSelectedUTCHour = now.getUTCHours(); + tzRenderGrid(); +} + +// Date input change +document.getElementById('tz-date').addEventListener('change', function() { + tzBaseDate = new Date(this.value + 'T00:00:00Z'); + tzRenderGrid(); +}); + +// ── Add timezone search ── +const tzSearchInput = document.getElementById('tz-search'); +const tzSugBox = document.getElementById('tz-suggestions'); +let tzSugIdx = -1; + +tzSearchInput.addEventListener('input', function() { + const q = this.value.trim().toLowerCase(); + tzSugBox.innerHTML = ''; + tzSugIdx = -1; + if (!q) { tzSugBox.classList.remove('open'); return; } + + // Check if query is an offset search like "+5", "-3", "+5.5", "5.5", "utc+5" + const offsetMatch = q.replace(/^utc\s*/, '').match(/^([+-]?\d+\.?\d*)$/); + const searchOffset = offsetMatch ? parseFloat(offsetMatch[1]) : null; + + const matches = TZ_CATALOG.filter(tz => { + const already = tzRows.some(r => r.name === tz.name && r.country === tz.country); + if (already) return false; + + // Match by offset number + if (searchOffset !== null && tz.offset === searchOffset) return true; + + // Match by name, country, or abbreviation + return tz.name.toLowerCase().includes(q) + || tz.country.toLowerCase().includes(q) + || tz.abbr.toLowerCase().includes(q); + }).slice(0, 10); + + if (!matches.length) { tzSugBox.classList.remove('open'); return; } + + matches.forEach((tz, i) => { + const item = document.createElement('div'); + item.className = 'tz-sug-item'; + item.innerHTML = `${tz.name}, ${tz.country}${tz.abbr} · UTC ${offsetLabel(tz.offset)}`; + item.addEventListener('click', () => tzAddRow(tz)); + tzSugBox.appendChild(item); + }); + tzSugBox.classList.add('open'); +}); + +tzSearchInput.addEventListener('keydown', function(e) { + const items = tzSugBox.querySelectorAll('.tz-sug-item'); + if (!items.length) return; + if (e.key === 'ArrowDown') { e.preventDefault(); tzSugIdx = Math.min(tzSugIdx + 1, items.length - 1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); tzSugIdx = Math.max(tzSugIdx - 1, 0); } + else if (e.key === 'Enter' && tzSugIdx >= 0) { e.preventDefault(); items[tzSugIdx].click(); return; } + else if (e.key === 'Escape') { tzSugBox.classList.remove('open'); return; } + else return; + items.forEach((it, i) => it.classList.toggle('active', i === tzSugIdx)); +}); + +tzSearchInput.addEventListener('blur', () => setTimeout(() => tzSugBox.classList.remove('open'), 150)); + +function tzAddRow(tz) { + tzRows.push({ ...tz }); + tzSearchInput.value = ''; + tzSugBox.classList.remove('open'); + tzRenderGrid(); +} + +// Init on load +tzSetToday(); +/* ── IP / Subnet Calculator ── */ +function parseIPv4(str) { + const parts = str.trim().split('.'); + if (parts.length !== 4) return null; + const nums = parts.map(p => { const n = parseInt(p, 10); return (isNaN(n) || n < 0 || n > 255 || String(n) !== p.trim()) ? -1 : n; }); + if (nums.some(n => n < 0)) return null; + return nums; +} + +function ipToInt(parts) { return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; } +function intToIP(n) { return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.'); } +function intToBin(n) { + return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255] + .map(b => b.toString(2).padStart(8, '0')).join('.'); +} + +function maskFromCIDR(cidr) { return cidr === 0 ? 0 : (0xFFFFFFFF << (32 - cidr)) >>> 0; } + +function cidrFromMask(maskInt) { + let bits = 0; let found = false; + for (let i = 31; i >= 0; i--) { + if (maskInt & (1 << i)) { if (found) return -1; bits++; } + else { found = true; } + } + return bits; +} + +function ipClass(firstOctet) { + if (firstOctet < 128) return 'A'; + if (firstOctet < 192) return 'B'; + if (firstOctet < 224) return 'C'; + if (firstOctet < 240) return 'D'; + return 'E'; +} + +function isPrivate(ipInt) { + const a = (ipInt >>> 24) & 255, b = (ipInt >>> 16) & 255; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 127) return true; + return false; +} + +function subnetCalc() { + const errEl = document.getElementById('subnet-err'); + const resultsEl = document.getElementById('subnet-results'); + errEl.textContent = ''; + resultsEl.style.display = 'none'; + + const ipStr = document.getElementById('subnet-ip').value.trim(); + const cidrStr = document.getElementById('subnet-cidr').value.trim(); + const maskStr = document.getElementById('subnet-mask-in').value.trim(); + + const ipParts = parseIPv4(ipStr); + if (!ipParts) { errEl.textContent = 'Invalid IP address'; return; } + + let cidr; + if (maskStr) { + const maskParts = parseIPv4(maskStr); + if (!maskParts) { errEl.textContent = 'Invalid subnet mask'; return; } + cidr = cidrFromMask(ipToInt(maskParts)); + if (cidr < 0) { errEl.textContent = 'Invalid subnet mask (not contiguous)'; return; } + document.getElementById('subnet-cidr').value = cidr; + } else { + cidr = parseInt(cidrStr, 10); + if (isNaN(cidr) || cidr < 0 || cidr > 32) { errEl.textContent = 'CIDR must be 0–32'; return; } + } + + const ipInt = ipToInt(ipParts); + const maskInt = maskFromCIDR(cidr); + const wildcardInt = (~maskInt) >>> 0; + const networkInt = (ipInt & maskInt) >>> 0; + const broadcastInt = (networkInt | wildcardInt) >>> 0; + const totalHosts = Math.pow(2, 32 - cidr); + const usableHosts = cidr >= 31 ? totalHosts : totalHosts - 2; + const firstHost = cidr >= 31 ? networkInt : (networkInt + 1) >>> 0; + const lastHost = cidr >= 31 ? broadcastInt : (broadcastInt - 1) >>> 0; + const cls = ipClass(ipParts[0]); + const priv = isPrivate(ipInt); + + const grid = document.getElementById('subnet-grid'); + const rows = [ + ['Network Address', intToIP(networkInt) + '/' + cidr], + ['Subnet Mask', intToIP(maskInt)], + ['Wildcard Mask', intToIP(wildcardInt)], + ['Broadcast Address', intToIP(broadcastInt)], + ['First Usable Host', intToIP(firstHost)], + ['Last Usable Host', intToIP(lastHost)], + ['Total Hosts', totalHosts.toLocaleString()], + ['Usable Hosts', usableHosts.toLocaleString()], + ['IP Class', 'Class ' + cls], + ['IP Type', priv ? 'Private (RFC 1918)' : 'Public'], + ]; + grid.innerHTML = rows.map(([label, value]) => + '
' + label + '' + value + '
' + ).join(''); + + // Binary breakdown + const binSection = document.getElementById('subnet-binary-section'); + const binEl = document.getElementById('subnet-binary'); + const ipBin = intToBin(ipInt); + const maskBin = intToBin(maskInt); + const netBin = intToBin(networkInt); + + // Color network vs host bits + const allBits = ipBin.replace(/\./g, ''); + const netPart = '' + allBits.slice(0, cidr) + ''; + const hostPart = '' + allBits.slice(cidr) + ''; + // Re-insert dots + function dotify(bits) { + return bits.match(/.{8}/g).join('.'); + } + + binEl.innerHTML = [ + ['IP Address', ipBin], + ['Subnet Mask', maskBin], + ['Network', intToBin(networkInt)], + ].map(([label, val]) => + '
' + label + '' + val + '
' + ).join('') + + '
Bits' + + '' + dotify(allBits.slice(0, cidr).padEnd(32, '\u2007').slice(0, 32)) + '' + + ' ← ' + cidr + ' network / ' + (32 - cidr) + ' host
'; + binSection.style.display = ''; + + // Subnet table (only if reasonable number, <= 256 subnets) + const allSection = document.getElementById('subnet-all-section'); + const tbody = document.getElementById('subnet-table-body'); + if (totalHosts <= 1 || cidr < 16) { + allSection.style.display = 'none'; + } else { + // Find the classful boundary to list sibling subnets + let parentCidr; + if (ipParts[0] < 128) parentCidr = 8; + else if (ipParts[0] < 192) parentCidr = 16; + else parentCidr = 24; + if (cidr <= parentCidr) { allSection.style.display = 'none'; } + else { + const parentMask = maskFromCIDR(parentCidr); + const parentNet = (ipInt & parentMask) >>> 0; + const subnetCount = Math.pow(2, cidr - parentCidr); + if (subnetCount > 256) { allSection.style.display = 'none'; } + else { + const subnetSize = Math.pow(2, 32 - cidr); + let html = ''; + for (let i = 0; i < subnetCount; i++) { + const sNet = (parentNet + i * subnetSize) >>> 0; + const sBcast = (sNet + subnetSize - 1) >>> 0; + const sFirst = cidr >= 31 ? sNet : (sNet + 1) >>> 0; + const sLast = cidr >= 31 ? sBcast : (sBcast - 1) >>> 0; + const isCurrent = sNet === networkInt; + html += '' + + intToIP(sNet) + '/' + cidr + '' + + intToIP(sFirst) + ' – ' + intToIP(sLast) + '' + + intToIP(sBcast) + ''; + } + tbody.innerHTML = html; + allSection.style.display = ''; + } + } + } + + resultsEl.style.display = ''; +} + +function subnetClear() { + document.getElementById('subnet-ip').value = ''; + document.getElementById('subnet-cidr').value = '24'; + document.getElementById('subnet-mask-in').value = ''; + document.getElementById('subnet-err').textContent = ''; + document.getElementById('subnet-results').style.display = 'none'; +} + +// Auto-calculate on Enter +document.getElementById('subnet-ip').addEventListener('keydown', e => { if (e.key === 'Enter') subnetCalc(); }); +document.getElementById('subnet-cidr').addEventListener('keydown', e => { if (e.key === 'Enter') subnetCalc(); }); +document.getElementById('subnet-mask-in').addEventListener('keydown', e => { if (e.key === 'Enter') subnetCalc(); }); + +// Sync CIDR ↔ mask +document.getElementById('subnet-mask-in').addEventListener('input', function() { + const parts = parseIPv4(this.value); + if (parts) { + const c = cidrFromMask(ipToInt(parts)); + if (c >= 0) document.getElementById('subnet-cidr').value = c; + } +}); +document.getElementById('subnet-cidr').addEventListener('input', function() { + const c = parseInt(this.value, 10); + if (!isNaN(c) && c >= 0 && c <= 32) { + document.getElementById('subnet-mask-in').value = intToIP(maskFromCIDR(c)); + } +}); + +/* ── Tool Search ── */ +(function() { + const searchInput = document.getElementById('tool-search'); + const searchResults = document.getElementById('search-results'); + if (!searchInput || !searchResults) return; + + // Build tool index from nav buttons + const toolIndex = []; + document.querySelectorAll('nav button[data-tab]').forEach(btn => { + const cat = btn.closest('.nav-category'); + let catName = ''; + if (cat) { + const hdr = cat.querySelector('.cat-header'); + if (hdr) { + const clone = hdr.cloneNode(true); + clone.querySelectorAll('.material-symbols-outlined, .cat-arrow').forEach(el => el.remove()); + catName = clone.textContent.trim(); + } + } + toolIndex.push({ + id: btn.dataset.tab, + name: btn.textContent.trim(), + category: catName + }); + }); + + let selIdx = -1; + + function renderResults(query) { + searchResults.innerHTML = ''; + selIdx = -1; + if (!query) { searchResults.classList.remove('open'); return; } + const q = query.toLowerCase(); + const matches = toolIndex.filter(t => + t.name.toLowerCase().includes(q) || t.category.toLowerCase().includes(q) || t.id.toLowerCase().includes(q) + ); + if (!matches.length) { + searchResults.innerHTML = '
No tools found
'; + searchResults.classList.add('open'); + return; + } + matches.forEach((t, i) => { + const item = document.createElement('div'); + item.className = 'search-result-item'; + item.innerHTML = '' + t.name + '' + t.category + ''; + item.addEventListener('click', () => { + navigateToTab(t.id); + searchInput.value = ''; + searchResults.classList.remove('open'); + searchInput.blur(); + }); + searchResults.appendChild(item); + }); + searchResults.classList.add('open'); + } + + searchInput.addEventListener('input', () => renderResults(searchInput.value.trim())); + + searchInput.addEventListener('keydown', function(e) { + const items = searchResults.querySelectorAll('.search-result-item'); + if (!items.length) return; + if (e.key === 'ArrowDown') { e.preventDefault(); selIdx = Math.min(selIdx + 1, items.length - 1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); selIdx = Math.max(selIdx - 1, 0); } + else if (e.key === 'Enter' && selIdx >= 0) { e.preventDefault(); items[selIdx].click(); return; } + else if (e.key === 'Escape') { searchResults.classList.remove('open'); searchInput.blur(); return; } + else return; + items.forEach((it, i) => it.classList.toggle('active', i === selIdx)); + items[selIdx]?.scrollIntoView({ block: 'nearest' }); + }); + + searchInput.addEventListener('blur', () => setTimeout(() => searchResults.classList.remove('open'), 150)); + searchInput.addEventListener('focus', () => { if (searchInput.value.trim()) renderResults(searchInput.value.trim()); }); +})(); diff --git a/index.html b/index.html index 37ccf817..f7a4446d 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,11 @@ WSO2

Support Utils

+
+ search + +
+
@@ -26,7 +31,7 @@

Support Utils

sync_alt Encode / Decode
- + @@ -36,6 +41,8 @@

Support Utils

+ +
@@ -46,6 +53,7 @@

Support Utils

+
@@ -57,6 +65,7 @@

Support Utils

+ @@ -70,14 +79,186 @@

Support Utils

+ + +
+ +
+
+

Support Utils

+

Browser-based utilities for WSO2 Customer Success engineers.

+

All processing happens locally, no data leaves your browser.

+
+ +
+

sync_alt Encode / Decode

+
+ + + + + + + + + + + + +
+
+ +
+

data_object Format

+
+ + + +
+
+ +
+

add_circle Generate

+
+ + + + +
+
+ +
+

handyman Utilities

+
+ + + + + + + + +
+
+
+ + -
+
B Base64 Encode
@@ -327,6 +508,40 @@

Support Utils

+ +
+
+ Y YAML Linter / Formatter +
+
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + +
+
+
@@ -511,6 +726,100 @@

Support Utils

+ +
+
+ 📜 CSR Generator +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + DNS names and IP addresses, comma-separated +
+
+
+ + +
+
+ + +
+
+
+
+
+ + + +
+ +
+
@@ -624,6 +933,153 @@

Support Utils

+ +
+
+ 🔒 PEM / Certificate Decoder +
+
+ + +
+
+
+ + + +
+ +
+ + +
+
+ 🌍 Time Zone Converter +
+ + +
+
+
IST — India Standard Time
+
--:--:--
+
+
UTC +05:30
+
+
+
BRT — Brasília Time
+
--:--:--
+
+
UTC −03:00
+
+
+ + +
+ + + +
+ +
+
+
+ + +
+
+ + +
+
+ 📋 CSR Decoder +
+
+ + +
+
+
+ + + +
+ +
+ + +
+
+ 🔐 Keystore / Truststore Viewer +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+ + +
+ + +
+
+ lan IP / Subnet Calculatorulator +
+ +
+
+ + +
+
+ +
+ / + +
+
+
+ + +
+
+ +
+ + + +
+
+ + +
+
diff --git a/style.css b/style.css index dbc56b0f..200cab05 100644 --- a/style.css +++ b/style.css @@ -549,6 +549,323 @@ input[type="range"] { flex: 1; accent-color: var(--accent); cursor: pointer; } .cors-checkboxes label { font-size: 0.82rem; cursor: pointer; display: flex; align-items: center; gap: 5px; } .cors-checkboxes input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } +/* ── PEM / Certificate Decoder ── */ +.pem-cert-title { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent); padding: 8px 0 4px; } +.pem-cert { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.pem-status { padding: 8px 14px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; text-align: center; } +.pem-valid { background: rgba(62,207,142,0.12); color: var(--success); } +.pem-expired { background: rgba(239,68,68,0.12); color: var(--danger); } +.pem-section { border-top: 1px solid var(--border); padding: 10px 14px; } +.pem-section-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 6px; } +.pem-field { display: flex; align-items: flex-start; gap: 10px; padding: 3px 0; font-size: 0.82rem; } +.pem-label { font-size: 0.72rem; font-weight: 600; color: var(--muted); width: 100px; flex-shrink: 0; padding-top: 1px; } +.pem-value { font-family: var(--mono); font-size: 0.78rem; color: var(--text); flex: 1; word-break: break-word; } +.pem-hash { font-size: 0.72rem; word-break: break-all; } +.pem-sans { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; } +.pem-san-tag { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 3px 8px; font-size: 0.75rem; font-family: var(--mono); color: var(--text); } + +/* ── Time Converter ── */ +.tz-hero { display: flex; gap: 16px; margin-bottom: 20px; } +.tz-hero-card { + flex: 1; background: var(--surface2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; text-align: center; +} +.tz-hero-label { font-size: 0.78rem; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.04em; } +.tz-hero-time { font-family: var(--mono); font-size: 2.2rem; font-weight: 600; color: var(--accent); line-height: 1.2; } +.tz-hero-date { font-size: 0.82rem; color: var(--text); margin-top: 4px; } +.tz-hero-offset { font-size: 0.72rem; color: var(--muted); margin-top: 6px; } + +/* Toolbar */ +.tz-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.tz-toolbar .text-input { padding: 7px 10px; font-size: 0.82rem; } +.tz-add-wrap { position: relative; } +.tz-add-wrap input { width: 220px; } +.tz-suggestions { + display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 50; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + max-height: 220px; overflow-y: auto; margin-top: 4px; box-shadow: 0 8px 24px rgba(0,0,0,.35); +} +.tz-suggestions.open { display: block; } +.tz-sug-item { + padding: 8px 12px; font-size: 0.8rem; cursor: pointer; display: flex; justify-content: space-between; +} +.tz-sug-item:hover, .tz-sug-item.active { background: var(--surface2); } +.tz-sug-offset { color: var(--muted); font-size: 0.72rem; } + +/* Grid (WorldTimeBuddy-style) */ +.tz-grid { display: flex; flex-direction: column; gap: 0; } + +.tz-row { + display: flex; align-items: stretch; border-bottom: 1px solid var(--border); + position: relative; +} +.tz-row:first-child { border-top: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; } +.tz-row:last-child { border-radius: 0 0 var(--radius) var(--radius); } + +.tz-row-label { + width: 160px; min-width: 160px; padding: 10px 12px; + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; justify-content: center; gap: 2px; +} +.tz-row-name { font-size: 0.8rem; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tz-row-meta { font-size: 0.68rem; color: var(--muted); } +.tz-row-remove { + position: absolute; top: 4px; left: 140px; z-index: 5; + background: none; border: none; color: var(--muted); cursor: pointer; font-size: 0.7rem; + opacity: 0; transition: opacity .15s; +} +.tz-row:hover .tz-row-remove { opacity: 1; } +.tz-row-remove:hover { color: var(--danger); } + +.tz-hours { + display: flex; flex: 1; overflow-x: auto; position: relative; + scrollbar-width: thin; scrollbar-color: var(--border) transparent; +} + +.tz-cell { + min-width: 42px; flex-shrink: 0; padding: 8px 2px; + text-align: center; cursor: pointer; transition: outline .1s; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; + border-right: 1px solid rgba(255,255,255,.04); + user-select: none; position: relative; +} +.tz-cell-hour { font-size: 0.7rem; font-weight: 600; } +.tz-cell-ampm { font-size: 0.58rem; font-weight: 400; opacity: .7; } + +/* Hour color bands */ +.tz-cell.tz-day { background: #1a5c2a; color: #c8f7d5; } +.tz-cell.tz-fringe { background: #6b5e10; color: #f5eab8; } +.tz-cell.tz-night { background: #1a1a2e; color: #6a6a8a; } + +[data-theme="light"] .tz-cell.tz-day { background: #d4edda; color: #155724; } +[data-theme="light"] .tz-cell.tz-fringe { background: #fff3cd; color: #856404; } +[data-theme="light"] .tz-cell.tz-night { background: #d6d8e1; color: #5a5a7a; } + +/* Selected column highlight */ +.tz-cell.tz-selected { + outline: 2px solid var(--accent); outline-offset: -2px; + z-index: 2; +} +.tz-cell.tz-selected .tz-cell-hour { font-weight: 700; } + +/* Midnight / date-change marker */ +.tz-cell.tz-midnight { border-left: 2px solid var(--accent); } + +@media (max-width: 600px) { + .tz-hero { flex-direction: column; } + .tz-row-label { width: 110px; min-width: 110px; padding: 8px; } + .tz-row-name { font-size: 0.72rem; } + .tz-cell { min-width: 36px; } +} + +/* ── Homepage ── */ +.home-hero { text-align: center; padding: 32px 0 28px; } +.home-title { font-size: 1.5rem; font-weight: 700; color: var(--text); margin-bottom: 8px; } +.home-subtitle { font-size: 0.88rem; color: var(--muted); max-width: 520px; margin: 0 auto; line-height: 1.6; } +.home-category { margin-bottom: 28px; } +.home-cat-title { + font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--muted); display: flex; align-items: center; gap: 6px; + margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); +} +.home-cat-title .material-symbols-outlined { font-size: 18px; } +.home-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } +.home-card { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 18px 16px; cursor: pointer; text-align: left; font-family: inherit; + display: flex; flex-direction: column; gap: 4px; + transition: background 0.15s, border-color 0.15s, transform 0.1s; + color: inherit; +} +.home-card:hover { background: var(--surface2); border-color: var(--accent); transform: translateY(-2px); } +.home-card:active { transform: scale(0.98); } +.home-card-icon { font-size: 28px; color: var(--accent); margin-bottom: 4px; } +.home-card-name { font-size: 0.88rem; font-weight: 600; color: var(--text); } +.home-card-desc { font-size: 0.76rem; color: var(--muted); line-height: 1.4; } + +@media (max-width: 760px) { + .home-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } +} + +/* ── CSR Generator ── */ +.csr-form { + display: flex; flex-direction: column; gap: 14px; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 20px; margin-bottom: 14px; +} +.csr-pair { display: flex; gap: 14px; } +.csr-pair .csr-field { flex: 1; } +.csr-field { display: flex; flex-direction: column; gap: 4px; } +.csr-label { font-size: 0.8rem; font-weight: 600; color: var(--text); } +.csr-required { color: var(--danger); font-weight: 700; } +.csr-hint { font-size: 0.72rem; color: var(--muted); } +.csr-form .text-input { + padding: 9px 12px; font-size: 0.85rem; + background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); +} +.csr-form .text-input:focus { border-color: var(--accent); outline: none; } +.csr-form select.text-input { cursor: pointer; } + +.csr-output-box { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 14px; display: flex; flex-direction: column; gap: 8px; +} +.csr-output-header { display: flex; justify-content: space-between; align-items: center; } +.csr-output-header label { font-size: 0.82rem; font-weight: 600; color: var(--text); } +.csr-output-actions { display: flex; gap: 6px; } +.csr-output-actions .btn { padding: 4px 12px; font-size: 0.72rem; } +.csr-output-box textarea { + height: 150px; font-family: var(--mono); font-size: 0.76rem; resize: vertical; + background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); padding: 10px; +} + +.csr-warning { + font-size: 0.78rem; color: var(--warning); background: rgba(245,158,11,.08); + border: 1px solid rgba(245,158,11,.2); border-radius: var(--radius); + padding: 10px 14px; line-height: 1.5; +} +@media (max-width: 600px) { .csr-pair { flex-direction: column; } } + +/* ── Keystore Viewer ── */ +.ks-upload-area { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; } +.ks-upload-area .io-box { flex: 1; min-width: 200px; } +.ks-summary { + background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); + padding: 10px 16px; font-size: 0.82rem; color: var(--text); margin-top: 12px; +} +.ks-entry { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 16px; overflow: hidden; +} +.ks-entry-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } +.ks-alias { font-family: var(--mono); font-size: 0.85rem; font-weight: 600; color: var(--text); } +.ks-ts { font-size: 0.72rem; color: var(--muted); } +.ks-badge { + font-size: 0.68rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; + text-transform: uppercase; letter-spacing: 0.03em; +} +.ks-badge-cert { background: #1a3a2a; color: var(--success); border: 1px solid #2a5a3a; } +.ks-badge-key { background: #3a2a1a; color: var(--warning); border: 1px solid #5a4a2a; } +[data-theme="light"] .ks-badge-cert { background: #d4edda; color: #155724; border-color: #b8dacc; } +[data-theme="light"] .ks-badge-key { background: #fff3cd; color: #856404; border-color: #ffe69c; } +.ks-no-cert { font-size: 0.8rem; color: var(--muted); font-style: italic; padding: 8px 0; } + +/* ── IP / Subnet Calculator ── */ +.subnet-input-row { display: flex; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; } +.subnet-field { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; } +.subnet-field-sm { flex: 0 0 120px; min-width: 100px; } +.subnet-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); } +.subnet-cidr-row { display: flex; align-items: center; gap: 0; } +.subnet-slash { font-family: var(--mono); font-size: 1.1rem; font-weight: 700; color: var(--accent); padding-right: 4px; } +.subnet-cidr-row .text-input { width: 100%; } + +.subnet-results-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; +} +.subnet-result-item { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); +} +.subnet-result-label { + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--muted); width: 130px; flex-shrink: 0; +} +.subnet-result-value { + font-family: var(--mono); font-size: 0.82rem; color: var(--text); flex: 1; word-break: break-all; +} + +.subnet-section { margin-top: 20px; } +.subnet-section-title { + font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--muted); margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); +} + +.subnet-binary { + display: flex; flex-direction: column; gap: 6px; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; +} +.subnet-bin-row { display: flex; align-items: center; gap: 12px; font-size: 0.82rem; } +.subnet-bin-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); width: 110px; flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.04em; } +.subnet-bin-value { font-family: var(--mono); font-size: 0.78rem; color: var(--text); letter-spacing: 0.5px; } +.subnet-bin-net { color: var(--accent); } +.subnet-bin-host { color: var(--success); } + +.subnet-table-wrap { max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius); } +.subnet-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; } +.subnet-table th { + background: var(--surface2); padding: 8px 12px; text-align: left; font-size: 0.72rem; + font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); + position: sticky; top: 0; z-index: 1; +} +.subnet-table td { padding: 6px 12px; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 0.78rem; color: var(--text); } +.subnet-table tr.subnet-current { background: rgba(255,115,0,0.08); } + +@media (max-width: 760px) { + .subnet-results-grid { grid-template-columns: 1fr; } + .subnet-input-row { flex-direction: column; } + .subnet-field-sm { flex: 1; } +} + +/* ── Search bar ── */ +.search-wrap { + position: relative; + display: flex; + align-items: center; + margin-right: 8px; +} +.search-icon { + position: absolute; + left: 10px; + font-size: 18px; + color: var(--muted); + pointer-events: none; +} +.search-input { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 99px; + color: var(--text); + font-size: 0.82rem; + padding: 7px 14px 7px 34px; + width: 220px; + outline: none; + transition: border-color 0.15s, width 0.2s; +} +.search-input:focus { border-color: var(--accent); width: 280px; } +.search-input::placeholder { color: var(--muted); opacity: 0.6; } +.search-results { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 100; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + max-height: 320px; + overflow-y: auto; + margin-top: 6px; + box-shadow: 0 8px 24px rgba(0,0,0,.35); +} +.search-results.open { display: block; } +.search-result-item { + padding: 10px 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + font-size: 0.82rem; + transition: background 0.1s; +} +.search-result-item:hover, .search-result-item.active { background: var(--surface2); } +.search-result-item .material-symbols-outlined { font-size: 18px; color: var(--accent); } +.search-result-name { font-weight: 600; color: var(--text); } +.search-result-cat { font-size: 0.72rem; color: var(--muted); margin-left: auto; } +.search-no-results { padding: 12px 14px; font-size: 0.82rem; color: var(--muted); text-align: center; } + footer { background: var(--surface); border-top: 1px solid var(--border);