Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@
"type-graphql": "npm:@capaj/[email protected]",
"typescript": "^6.0.3",
"uuid": "^14.0.0",
"wrangler": "^4.86.0",
"zod": "^4.4.1"
"wrangler": "^4.87.0",
"zod": "^4.4.2"
},
"devDependencies": {
"@anatine/esbuild-decorators": "^0.2.19",
"@cjsa/cpy": "^9.0.1",
"@faker-js/faker": "^10.4.0",
"@mermaid-js/mermaid-cli": "^11.14.0",
"@swc/core": "^1.15.32",
"@swc/core": "^1.15.33",
"@types/bun": "^1.3.13",
"@types/graphql-list-fields": "^2.0.7",
"@types/lodash.set": "^4.3.9",
Expand Down
5 changes: 3 additions & 2 deletions mobile-app/lingui.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatter } from '@lingui/format-po'

export default {
locales: ['en', 'cs'],
sourceLocale: 'en',
Expand All @@ -8,8 +10,7 @@ export default {
exclude: ['**/node_modules/**']
}
],
format: 'po',
formatOptions: { origins: true, lineNumbers: false },
format: formatter({ origins: true, lineNumbers: false }),
pseudoLocale: 'pseudo',
fallbackLocales: {
pseudo: 'en'
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@
"lint-staged": "^16.4.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.8.3",
"turbo": "^2.9.6",
"turbo": "^2.9.8",
"typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1",
"zod": "^4.4.1",
"zod": "^4.4.2",
"zx": "^8.8.5"
},
"scripts": {
Expand Down
716 changes: 374 additions & 342 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

281 changes: 281 additions & 0 deletions shared/totp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import { generateSync } from 'otplib'

export type TotpOptions = {
secret: string
digits?: number
period?: number
now?: number
}

const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const minHmacSecretByteLength = 16

export const normalizeBase32Secret = (secret: string) =>
secret.toUpperCase().replace(/[\s=-]/g, '')

export const decodeBase32Secret = (secret: string): Uint8Array | null => {
const normalizedSecret = normalizeBase32Secret(secret)

if (!normalizedSecret) {
return null
}

let value = 0
let bits = 0
const bytes: number[] = []

for (const character of normalizedSecret) {
const characterIndex = base32Alphabet.indexOf(character)

if (characterIndex === -1) {
return null
}

value = (value << 5) | characterIndex
bits += 5

while (bits >= 8) {
bits -= 8
bytes.push((value >> bits) & 0xff)
value &= (1 << bits) - 1
}
}

return new Uint8Array(bytes)
}

const encodeCounter = (counter: number) => {
const bytes = new Uint8Array(8)
let remaining = BigInt(counter)

for (let index = bytes.length - 1; index >= 0; index -= 1) {
bytes[index] = Number(remaining & 0xffn)
remaining >>= 8n
}

return bytes
}

const normalizeLegacyHmacKey = (secretBytes: Uint8Array) => {
if (secretBytes.length >= minHmacSecretByteLength) {
return secretBytes
}

const paddedSecretBytes = new Uint8Array(minHmacSecretByteLength)
paddedSecretBytes.set(secretBytes)

return paddedSecretBytes
}

const rotateLeft = (value: number, bits: number) =>
(value << bits) | (value >>> (32 - bits))

const sha1 = (message: Uint8Array) => {
const bitLength = message.length * 8
const paddedLength = Math.ceil((message.length + 9) / 64) * 64
const paddedMessage = new Uint8Array(paddedLength)
paddedMessage.set(message)
paddedMessage[message.length] = 0x80

const view = new DataView(paddedMessage.buffer)
view.setUint32(paddedLength - 8, Math.floor(bitLength / 2 ** 32))
view.setUint32(paddedLength - 4, bitLength)

let h0 = 0x67452301
let h1 = 0xefcdab89
let h2 = 0x98badcfe
let h3 = 0x10325476
let h4 = 0xc3d2e1f0
const words = new Uint32Array(80)

for (let chunkOffset = 0; chunkOffset < paddedLength; chunkOffset += 64) {
for (let index = 0; index < 16; index += 1) {
words[index] = view.getUint32(chunkOffset + index * 4)
}

for (let index = 16; index < 80; index += 1) {
words[index] = rotateLeft(
words[index - 3] ^
words[index - 8] ^
words[index - 14] ^
words[index - 16],
1
)
}

let a = h0
let b = h1
let c = h2
let d = h3
let e = h4

for (let index = 0; index < 80; index += 1) {
let f = 0
let k = 0

if (index < 20) {
f = (b & c) | (~b & d)
k = 0x5a827999
} else if (index < 40) {
f = b ^ c ^ d
k = 0x6ed9eba1
} else if (index < 60) {
f = (b & c) | (b & d) | (c & d)
k = 0x8f1bbcdc
} else {
f = b ^ c ^ d
k = 0xca62c1d6
}

const temp = (rotateLeft(a, 5) + f + e + k + words[index]) >>> 0
e = d
d = c
c = rotateLeft(b, 30) >>> 0
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 hash = new Uint8Array(20)
const hashView = new DataView(hash.buffer)
hashView.setUint32(0, h0)
hashView.setUint32(4, h1)
hashView.setUint32(8, h2)
hashView.setUint32(12, h3)
hashView.setUint32(16, h4)

return hash
}

const hmacSha1 = (key: Uint8Array, message: Uint8Array) => {
const blockSize = 64
const normalizedKey = new Uint8Array(blockSize)
const blockKey = key.length > blockSize ? sha1(key) : key
normalizedKey.set(blockKey)

const outerPad = new Uint8Array(blockSize)
const innerPad = new Uint8Array(blockSize)

for (let index = 0; index < blockSize; index += 1) {
outerPad[index] = normalizedKey[index] ^ 0x5c
innerPad[index] = normalizedKey[index] ^ 0x36
}

const innerMessage = new Uint8Array(innerPad.length + message.length)
innerMessage.set(innerPad)
innerMessage.set(message, innerPad.length)

const innerHash = sha1(innerMessage)
const outerMessage = new Uint8Array(outerPad.length + innerHash.length)
outerMessage.set(outerPad)
outerMessage.set(innerHash, outerPad.length)

return sha1(outerMessage)
}

const generateLegacyTotpToken = ({
secret,
digits,
period,
now
}: Required<TotpOptions>): string | null => {
const secretBytes = decodeBase32Secret(secret)

if (secretBytes === null) {
return null
}

const counter = Math.floor(Math.round(now / 1000.0) / period)
const signature = hmacSha1(
normalizeLegacyHmacKey(secretBytes),
encodeCounter(counter)
)
const offset = signature[signature.length - 1] & 0x0f
const binaryCode =
((signature[offset] & 0x7f) << 24) |
(signature[offset + 1] << 16) |
(signature[offset + 2] << 8) |
signature[offset + 3]

return (binaryCode % 10 ** digits).toString().padStart(digits, '0')
}

export const generateTotpToken = async ({
secret,
digits = 6,
period = 30,
now = Date.now()
}: TotpOptions): Promise<string | null> => {
if (!Number.isInteger(digits) || digits <= 0) {
return null
}

if (!Number.isInteger(period) || period <= 0) {
return null
}

const secretBytes = decodeBase32Secret(secret)

if (secretBytes === null) {
return null
}

if (secretBytes.length < minHmacSecretByteLength) {
return generateLegacyTotpToken({
secret,
digits,
period,
now
})
}

return generateSync({
secret,
digits,
period,
epoch: Math.floor(now / 1000)
})
}

export const generateTotpTokenSync = ({
secret,
digits = 6,
period = 30,
now = Date.now()
}: TotpOptions) => {
if (!Number.isInteger(digits) || digits <= 0) {
return null
}

if (!Number.isInteger(period) || period <= 0) {
return null
}

const secretBytes = decodeBase32Secret(secret)

if (secretBytes === null) {
return null
}

if (secretBytes.length < minHmacSecretByteLength) {
return generateLegacyTotpToken({
secret,
digits,
period,
now
})
}

return generateSync({
secret,
digits,
period,
epoch: Math.floor(now / 1000)
})
}
2 changes: 1 addition & 1 deletion vault-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"react-hook-form": "^7.72.0",
"react-router-dom": "^7.6.2",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
"zod": "^4.4.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
Expand Down
17 changes: 16 additions & 1 deletion vault-web/src/lib/totp.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { formatTotpToken, generateTotpToken, getTotpRemainingSeconds } from './totp'
import {
formatTotpToken,
generateTotpToken,
getTotpRemainingSeconds
} from './totp'

describe('totp helpers', () => {
it('generates the expected 6-digit token for a known RFC test vector', async () => {
Expand All @@ -13,6 +17,17 @@ describe('totp helpers', () => {
expect(token).toBe('287082')
})

it('generates tokens for legacy short secrets', async () => {
const token = await generateTotpToken({
secret: 'JBSWY3DPEHPK3PXP',
digits: 6,
period: 30,
now: 59_000
})

expect(token).toBe('996554')
})

it('formats tokens into readable groups', () => {
expect(formatTotpToken('287082')).toBe('287 082')
expect(formatTotpToken('12345678')).toBe('123 456 78')
Expand Down
Loading
Loading