diff --git a/package-lock.json b/package-lock.json index 8ac4fd2..63ae647 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "hash-wasm": "4.12.0" + "hash-wasm": "4.12.0", + "otpauth": "^9.5.1" }, "devDependencies": { "@noble/post-quantum": "0.5.4", @@ -3239,6 +3240,30 @@ "node": ">= 0.8.0" } }, + "node_modules/otpauth": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", + "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/otpauth/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index f5cddf0..a08cc69 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,14 @@ "types": "./dist/integrity/index.d.ts", "import": "./dist/integrity/index.js" }, + "./signing": { + "types": "./dist/signing/index.d.ts", + "import": "./dist/signing/index.js" + }, + "./totp": { + "types": "./dist/totp/index.d.ts", + "import": "./dist/totp/index.js" + }, "./migrations": { "types": "./dist/migrations/index.d.ts", "import": "./dist/migrations/index.js" @@ -87,7 +95,8 @@ "test:watch": "vitest" }, "dependencies": { - "hash-wasm": "4.12.0" + "hash-wasm": "4.12.0", + "otpauth": "^9.5.1" }, "peerDependencies": { "@noble/post-quantum": "^0.5.0" diff --git a/src/aead/aead-raw.test.ts b/src/aead/aead-raw.test.ts new file mode 100644 index 0000000..fb52f59 --- /dev/null +++ b/src/aead/aead-raw.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + aesGcmDecrypt, + aesGcmEncrypt, + generateAesGcmKey, + importAesGcmRawKey, +} from './index.js'; +import { utf8ToBytes } from '../core/encoding.js'; +import { DisDecryptionError } from '../core/errors.js'; + +const KEY = new Uint8Array(32).fill(5); +const NONCE = new Uint8Array(12).fill(9); + +describe('aead — raw AES-256-GCM', () => { + it('round-trips with raw key bytes (ciphertext = ct||tag, no nonce prefix)', async () => { + const pt = utf8ToBytes('op-log record plaintext'); + const ct = await aesGcmEncrypt(KEY, NONCE, pt); + // ciphertext length = plaintext + 16-byte tag + expect(ct.length).toBe(pt.length + 16); + const back = await aesGcmDecrypt(KEY, NONCE, ct); + expect(Array.from(back)).toEqual(Array.from(pt)); + }); + + it('binds associated data', async () => { + const pt = utf8ToBytes('payload'); + const aad = utf8ToBytes('record-aad'); + const ct = await aesGcmEncrypt(KEY, NONCE, pt, aad); + const ok = await aesGcmDecrypt(KEY, NONCE, ct, aad); + expect(Array.from(ok)).toEqual(Array.from(pt)); + await expect(aesGcmDecrypt(KEY, NONCE, ct, utf8ToBytes('wrong-aad'))) + .rejects.toBeInstanceOf(DisDecryptionError); + await expect(aesGcmDecrypt(KEY, NONCE, ct)).rejects.toBeInstanceOf(DisDecryptionError); + }); + + it('throws DisDecryptionError on tampered ciphertext', async () => { + const ct = await aesGcmEncrypt(KEY, NONCE, utf8ToBytes('data')); + ct[0] ^= 0xff; + await expect(aesGcmDecrypt(KEY, NONCE, ct)).rejects.toBeInstanceOf(DisDecryptionError); + }); + + it('works with a generated non-extractable key and an imported raw key', async () => { + const gen = await generateAesGcmKey(); + const nonce = new Uint8Array(12).fill(2); + const ct = await aesGcmEncrypt(gen, nonce, utf8ToBytes('x')); + expect(Array.from(await aesGcmDecrypt(gen, nonce, ct))).toEqual(Array.from(utf8ToBytes('x'))); + + const imported = await importAesGcmRawKey(KEY, ['encrypt', 'decrypt']); + const ct2 = await aesGcmEncrypt(imported, NONCE, utf8ToBytes('y')); + // cross-check: raw-bytes decrypt matches imported-key encrypt + expect(Array.from(await aesGcmDecrypt(KEY, NONCE, ct2))).toEqual(Array.from(utf8ToBytes('y'))); + }); +}); diff --git a/src/aead/index.ts b/src/aead/index.ts index 575567a..2e644eb 100644 --- a/src/aead/index.ts +++ b/src/aead/index.ts @@ -95,6 +95,94 @@ export async function decryptBytes( } } +// --------------------------------------------------------------------------- +// Raw-mode AES-GCM +// +// The high-level helpers above own the IV and bundle `base64(IV || ct)`. Some +// Singra surfaces instead manage the nonce themselves and store the nonce and +// ciphertext as separate fields (op-log records, snapshots), bind binary AAD, +// or use a generated non-extractable wrapping key (local secret store). These +// primitives expose AES-256-GCM at that lower level while keeping all WebCrypto +// access inside DIS. Tag length is pinned to 128 bits. +// --------------------------------------------------------------------------- + +/** Imports raw key bytes as an AES-GCM key with the given usages. */ +export async function importAesGcmRawKey( + keyBytes: Uint8Array, + usages: KeyUsage[], +): Promise { + return subtle().importKey( + 'raw', + keyBytes as BufferSource, + { name: 'AES-GCM' }, + false, + usages, + ); +} + +/** Generates a fresh non-extractable AES-256-GCM key. */ +export async function generateAesGcmKey( + usages: KeyUsage[] = ['encrypt', 'decrypt'], + extractable = false, +): Promise { + return subtle().generateKey({ name: 'AES-GCM', length: 256 }, extractable, usages); +} + +/** + * AES-256-GCM encrypt with a caller-supplied nonce and optional binary AAD. + * Returns raw `ciphertext || authTag` bytes (no nonce prefix). `key` may be a + * raw 32-byte array or an already-imported `CryptoKey`. + */ +export async function aesGcmEncrypt( + key: CryptoKey | Uint8Array, + nonce: Uint8Array, + plaintext: Uint8Array, + associatedData?: Uint8Array, +): Promise { + const cryptoKey = key instanceof Uint8Array ? await importAesGcmRawKey(key, ['encrypt']) : key; + const ciphertext = await subtle().encrypt( + { + name: 'AES-GCM', + iv: nonce as BufferSource, + tagLength: AES_GCM_TAG_LENGTH, + ...(associatedData && { additionalData: associatedData as BufferSource }), + }, + cryptoKey, + plaintext as BufferSource, + ); + return new Uint8Array(ciphertext); +} + +/** + * AES-256-GCM decrypt with a caller-supplied nonce and optional binary AAD. + * `ciphertext` is raw `ciphertext || authTag` bytes. Throws + * {@link DisDecryptionError} on any failure (wrong key / tamper / AAD mismatch) + * without distinguishing the cause. + */ +export async function aesGcmDecrypt( + key: CryptoKey | Uint8Array, + nonce: Uint8Array, + ciphertext: Uint8Array, + associatedData?: Uint8Array, +): Promise { + const cryptoKey = key instanceof Uint8Array ? await importAesGcmRawKey(key, ['decrypt']) : key; + try { + const plaintext = await subtle().decrypt( + { + name: 'AES-GCM', + iv: nonce as BufferSource, + tagLength: AES_GCM_TAG_LENGTH, + ...(associatedData && { additionalData: associatedData as BufferSource }), + }, + cryptoKey, + ciphertext as BufferSource, + ); + return new Uint8Array(plaintext); + } catch { + throw new DisDecryptionError(); + } +} + /** Encrypts a UTF-8 string. The intermediate plaintext bytes are wiped. */ export async function encryptString( plaintext: string, diff --git a/src/core/encoding.ts b/src/core/encoding.ts index 7b7c830..c8da1be 100644 --- a/src/core/encoding.ts +++ b/src/core/encoding.ts @@ -46,6 +46,36 @@ export function bytesToUtf8(bytes: Uint8Array): string { return textDecoder.decode(bytes); } +/** + * Encodes bytes to an unpadded base64url (RFC 4648 §5) string. + * + * Reproduces the exact transform used by Singra Vault's `encodeBase64Url` + * (standard base64 with `+`→`-`, `/`→`_`, trailing `=` stripped) so DIS is + * byte-compatible with stored op-log signatures, hashes and public keys. + */ +export function bytesToBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +/** Decodes an unpadded base64url string to bytes. */ +export function base64UrlToBytes(base64url: string): Uint8Array { + const normalized = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + return base64ToBytes(padded); +} + +/** Lower-case hex string of the given bytes. */ +export function bytesToHex(bytes: Uint8Array): string { + let hex = ''; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, '0'); + } + return hex; +} + /** Concatenates byte arrays into a single new buffer. */ export function concatBytes(...parts: Uint8Array[]): Uint8Array { let total = 0; diff --git a/src/index.ts b/src/index.ts index 267db1a..4b06b3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,14 +84,39 @@ export { type SharedKeyWrapAadInput, } from './post-quantum/index.js'; -// Integrity +// Integrity & hashing export { verifyPayloadIntegrity, + sha256Bytes, sha256Base64, + sha256Base64Url, + sha256Hex, + sha1Hex, sha256JsonBase64, + hmacSha256, + hmacSha256WithKey, + importHmacSha256Key, constantTimeEqual, } from './integrity/index.js'; +// Digital signatures (ECDSA P-256) +export { + generateEcdsaP256KeyPair, + importEcdsaP256PublicKeySpki, + signEcdsaP256, + verifyEcdsaP256, + ECDSA_P256_SIGNATURE_LENGTH, + type EcdsaP256KeyPair, +} from './signing/index.js'; + +// Time-based one-time passwords (TOTP) +export { + generateTotpSecret, + buildTotpUri, + verifyTotpCode, + type TotpParams, +} from './totp/index.js'; + // Migrations (migrateEncryptedPayload via the registry) export { MigrationRegistry, @@ -107,15 +132,26 @@ export { decryptBytes, encryptString, decryptString, + aesGcmEncrypt, + aesGcmDecrypt, + importAesGcmRawKey, + generateAesGcmKey, } from './aead/index.js'; +export { + argon2idRaw, + deriveHkdfSha256Bits, + deriveHkdfAesGcmKey, + type Argon2idRawParams, +} from './kdf/index.js'; + export { SecureBuffer, withSecureBuffer, zeroBuffers, } from './secure-memory/index.js'; -export { randomBytes, randomUuid } from './random/index.js'; +export { randomBytes, randomInt, fillRandom, randomUuid } from './random/index.js'; export { formatEnvelope, diff --git a/src/integrity/index.ts b/src/integrity/index.ts index 5d9b399..4aad3e4 100644 --- a/src/integrity/index.ts +++ b/src/integrity/index.ts @@ -8,13 +8,73 @@ */ import { subtle } from '../core/provider.js'; -import { base64ToBytes, bytesToBase64, utf8ToBytes } from '../core/encoding.js'; +import { + base64ToBytes, + bytesToBase64, + bytesToBase64Url, + bytesToHex, + utf8ToBytes, +} from '../core/encoding.js'; import { DisIntegrityError } from '../core/errors.js'; +/** SHA-256 of raw bytes, returned as a fresh `Uint8Array` digest. */ +export async function sha256Bytes(data: Uint8Array): Promise { + const digest = await subtle().digest('SHA-256', data as BufferSource); + return new Uint8Array(digest); +} + /** SHA-256 of raw bytes, base64-encoded. */ export async function sha256Base64(data: Uint8Array): Promise { - const digest = await subtle().digest('SHA-256', data as BufferSource); - return bytesToBase64(new Uint8Array(digest)); + return bytesToBase64(await sha256Bytes(data)); +} + +/** SHA-256 of raw bytes, unpadded-base64url-encoded. */ +export async function sha256Base64Url(data: Uint8Array): Promise { + return bytesToBase64Url(await sha256Bytes(data)); +} + +/** SHA-256 of raw bytes, lower-case hex-encoded. */ +export async function sha256Hex(data: Uint8Array): Promise { + return bytesToHex(await sha256Bytes(data)); +} + +/** + * SHA-1 of raw bytes, lower-case hex-encoded. + * + * SHA-1 is collision-broken and MUST NOT be used for any security decision. + * It is provided solely for legacy interop where a remote protocol mandates + * it — specifically the HaveIBeenPwned k-anonymity range API, which keys on + * the SHA-1 of the password. Callers uppercase the hex as the API requires. + */ +export async function sha1Hex(data: Uint8Array): Promise { + const digest = await subtle().digest('SHA-1', data as BufferSource); + return bytesToHex(new Uint8Array(digest)); +} + +/** Imports raw bytes as an HMAC-SHA-256 key. */ +export async function importHmacSha256Key( + keyBytes: Uint8Array, + usages: KeyUsage[] = ['sign', 'verify'], +): Promise { + return subtle().importKey( + 'raw', + keyBytes as BufferSource, + { name: 'HMAC', hash: 'SHA-256' }, + false, + usages, + ); +} + +/** Computes HMAC-SHA-256 over `data` with an already-imported key. */ +export async function hmacSha256WithKey(key: CryptoKey, data: Uint8Array): Promise { + const sig = await subtle().sign('HMAC', key, data as BufferSource); + return new Uint8Array(sig); +} + +/** Computes HMAC-SHA-256 over `data` with raw key bytes. */ +export async function hmacSha256(keyBytes: Uint8Array, data: Uint8Array): Promise { + const key = await importHmacSha256Key(keyBytes, ['sign']); + return hmacSha256WithKey(key, data); } /** SHA-256 of a UTF-8 string, base64-encoded. */ diff --git a/src/integrity/integrity-ext.test.ts b/src/integrity/integrity-ext.test.ts new file mode 100644 index 0000000..14e2cb1 --- /dev/null +++ b/src/integrity/integrity-ext.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + hmacSha256, + hmacSha256WithKey, + importHmacSha256Key, + sha1Hex, + sha256Base64Url, + sha256Bytes, + sha256Hex, +} from './index.js'; +import { utf8ToBytes } from '../core/encoding.js'; + +// NIST / RFC known-answer vectors. +const ABC = utf8ToBytes('abc'); + +describe('integrity — hashing extensions', () => { + it('matches the SHA-256("abc") known-answer vector', async () => { + expect(await sha256Hex(ABC)).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + expect((await sha256Bytes(ABC)).length).toBe(32); + }); + + it('matches the SHA-1("abc") known-answer vector', async () => { + expect(await sha1Hex(ABC)).toBe('a9993e364706816aba3e25717850c26c9cd0d89d'); + }); + + it('produces unpadded base64url (no +, /, =)', async () => { + const out = await sha256Base64Url(ABC); + expect(out).not.toMatch(/[+/=]/); + }); +}); + +describe('integrity — HMAC-SHA-256', () => { + // RFC 4231 test case 1: key = 0x0b*20, data = "Hi There". + const key = new Uint8Array(20).fill(0x0b); + const data = utf8ToBytes('Hi There'); + const expected = + 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'; + + function toHex(bytes: Uint8Array): string { + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + it('matches the RFC 4231 vector via raw-key helper', async () => { + expect(toHex(await hmacSha256(key, data))).toBe(expected); + }); + + it('matches via an imported CryptoKey and is verifiable', async () => { + const k = await importHmacSha256Key(key); + expect(toHex(await hmacSha256WithKey(k, data))).toBe(expected); + }); +}); diff --git a/src/kdf/index.ts b/src/kdf/index.ts index 34118b0..e51232b 100644 --- a/src/kdf/index.ts +++ b/src/kdf/index.ts @@ -169,3 +169,98 @@ export async function deriveAesGcmKey( keyBytes.fill(0); } } + +/** Explicit Argon2id parameters for a single ad-hoc derivation. */ +export interface Argon2idRawParams { + readonly password: string; + readonly salt: Uint8Array; + /** Memory cost in KiB. */ + readonly memorySize: number; + readonly iterations: number; + readonly parallelism: number; + readonly hashLength: number; +} + +/** + * Low-level Argon2id derivation returning raw key bytes. This is the audited + * `hash-wasm` Argon2id with caller-chosen parameters and a raw byte salt — for + * call sites that derive a key with a context-specific salt/param set (device + * key transfer wrapping, integrity HMAC key) rather than the versioned account + * KDF registry used by {@link deriveRawKey}. + */ +export async function argon2idRaw(params: Argon2idRawParams): Promise { + if (typeof params.password !== 'string' || params.password.length === 0) { + throw new DisInvalidArgumentError('password must be a non-empty string'); + } + const result = await argon2id({ + password: params.password, + salt: params.salt, + parallelism: params.parallelism, + iterations: params.iterations, + memorySize: params.memorySize, + hashLength: params.hashLength, + outputType: 'binary', + }); + return new Uint8Array(result); +} + +/** + * HKDF-SHA-256 expand/extract producing `lengthBits` of key material. + * + * `ikm` is the input keying material, `salt` defaults to the empty salt (the + * op-log record/snapshot derivations use an empty salt and put all context in + * `info`; the device-key derivation uses the device key as salt). + */ +export async function deriveHkdfSha256Bits( + ikm: Uint8Array, + options: { info: Uint8Array; salt?: Uint8Array; lengthBits?: number }, +): Promise { + const baseKey = await subtle().importKey( + 'raw', + ikm as BufferSource, + { name: 'HKDF' }, + false, + ['deriveBits'], + ); + const bits = await subtle().deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: (options.salt ?? new Uint8Array(0)) as BufferSource, + info: options.info as BufferSource, + }, + baseKey, + options.lengthBits ?? 256, + ); + return new Uint8Array(bits); +} + +/** + * HKDF-SHA-256 deriving directly into a non-extractable AES-256-GCM key, + * mirroring `crypto.subtle.deriveKey` (used by passkey PRF wrapping and the + * legacy device-key wrapping path). + */ +export async function deriveHkdfAesGcmKey( + ikm: Uint8Array, + options: { info: Uint8Array; salt?: Uint8Array; usages?: KeyUsage[] }, +): Promise { + const baseKey = await subtle().importKey( + 'raw', + ikm as BufferSource, + 'HKDF', + false, + ['deriveKey'], + ); + return subtle().deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: (options.salt ?? new Uint8Array(0)) as BufferSource, + info: options.info as BufferSource, + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + options.usages ?? ['encrypt', 'decrypt'], + ); +} diff --git a/src/kdf/kdf-ext.test.ts b/src/kdf/kdf-ext.test.ts new file mode 100644 index 0000000..2b3f25b --- /dev/null +++ b/src/kdf/kdf-ext.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + argon2idRaw, + deriveHkdfAesGcmKey, + deriveHkdfSha256Bits, +} from './index.js'; +import { aesGcmDecrypt, aesGcmEncrypt } from '../aead/index.js'; +import { utf8ToBytes } from '../core/encoding.js'; + +describe('kdf — argon2idRaw', () => { + it('is deterministic for fixed params and salt', async () => { + const salt = new Uint8Array(16).fill(7); + const params = { + password: 'CODEABCD', + salt, + memorySize: 16384, + iterations: 2, + parallelism: 1, + hashLength: 32, + } as const; + const a = await argon2idRaw(params); + const b = await argon2idRaw(params); + expect(a.length).toBe(32); + expect(Array.from(a)).toEqual(Array.from(b)); + }); + + it('differs when the salt changes', async () => { + const base = { + password: 'CODEABCD', + memorySize: 16384, + iterations: 2, + parallelism: 1, + hashLength: 32, + }; + const a = await argon2idRaw({ ...base, salt: new Uint8Array(16).fill(1) }); + const b = await argon2idRaw({ ...base, salt: new Uint8Array(16).fill(2) }); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); +}); + +describe('kdf — HKDF-SHA-256', () => { + it('deriveHkdfSha256Bits is deterministic and length-controlled', async () => { + const ikm = utf8ToBytes('input-keying-material'); + const info = utf8ToBytes('context'); + const a = await deriveHkdfSha256Bits(ikm, { info }); + const b = await deriveHkdfSha256Bits(ikm, { info }); + expect(a.length).toBe(32); + expect(Array.from(a)).toEqual(Array.from(b)); + const long = await deriveHkdfSha256Bits(ikm, { info, lengthBits: 512 }); + expect(long.length).toBe(64); + }); + + it('salt changes the derived bits', async () => { + const ikm = utf8ToBytes('ikm'); + const info = utf8ToBytes('info'); + const noSalt = await deriveHkdfSha256Bits(ikm, { info }); + const withSalt = await deriveHkdfSha256Bits(ikm, { info, salt: new Uint8Array(32).fill(9) }); + expect(Array.from(noSalt)).not.toEqual(Array.from(withSalt)); + }); + + it('deriveHkdfAesGcmKey yields a usable AES-GCM key', async () => { + const ikm = new Uint8Array(32).fill(3); + const key = await deriveHkdfAesGcmKey(ikm, { info: utf8ToBytes('wrap') }); + const nonce = new Uint8Array(12).fill(1); + const pt = utf8ToBytes('secret'); + const ct = await aesGcmEncrypt(key, nonce, pt); + const back = await aesGcmDecrypt(key, nonce, ct); + expect(Array.from(back)).toEqual(Array.from(pt)); + }); +}); diff --git a/src/random/index.ts b/src/random/index.ts index f55dd12..7e751df 100644 --- a/src/random/index.ts +++ b/src/random/index.ts @@ -19,6 +19,48 @@ export function randomBytes(length: number): Uint8Array { return out; } +/** + * Fills an existing `ArrayBufferView` with cryptographically secure random + * bytes in place. Used by callers that own a buffer they want to overwrite + * (e.g. secure-memory scrubbing) without allocating a second array. + */ +export function fillRandom(view: T): T { + return getCryptoProvider().getRandomValues(view); +} + +/** + * Returns a cryptographically secure random integer in the inclusive range + * `[min, max]` using rejection sampling to avoid modulo bias. + * + * Byte-for-byte equivalent to the `getSecureRandomInt` helpers in Singra Vault + * (password generator, backup-code generator): it draws `ceil(log2(range)/8)` + * bytes big-endian and rejects values above the largest multiple of `range`. + */ +export function randomInt(min: number, max: number): number { + if (!Number.isInteger(min) || !Number.isInteger(max)) { + throw new DisInvalidArgumentError('randomInt bounds must be integers'); + } + if (max < min) { + throw new DisInvalidArgumentError('randomInt max must be >= min'); + } + const range = max - min + 1; + if (range === 1) { + return min; + } + const bytesNeeded = Math.ceil(Math.log2(range) / 8) || 1; + const maxValid = Math.floor((256 ** bytesNeeded) / range) * range - 1; + const buffer = new Uint8Array(bytesNeeded); + let randomValue: number; + do { + getCryptoProvider().getRandomValues(buffer); + randomValue = 0; + for (let i = 0; i < bytesNeeded; i++) { + randomValue = (randomValue << 8) | buffer[i]!; + } + } while (randomValue > maxValid); + return min + (randomValue % range); +} + /** Returns a RFC 4122 v4 UUID using the provider's CSPRNG. */ export function randomUuid(): string { const provider = getCryptoProvider() as { randomUUID?: () => string }; diff --git a/src/random/random-ext.test.ts b/src/random/random-ext.test.ts new file mode 100644 index 0000000..5879551 --- /dev/null +++ b/src/random/random-ext.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { fillRandom, randomInt } from './index.js'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +describe('random — randomInt', () => { + it('always returns values within the inclusive range', () => { + for (let i = 0; i < 2000; i++) { + const v = randomInt(0, 30); // 31 = backup-code charset size + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(30); + expect(Number.isInteger(v)).toBe(true); + } + }); + + it('returns the only value for a singleton range', () => { + expect(randomInt(7, 7)).toBe(7); + }); + + it('covers the full range over many draws', () => { + const seen = new Set(); + for (let i = 0; i < 5000; i++) seen.add(randomInt(0, 9)); + for (let n = 0; n <= 9; n++) expect(seen.has(n)).toBe(true); + }); + + it('rejects invalid bounds', () => { + expect(() => randomInt(5, 1)).toThrow(DisInvalidArgumentError); + expect(() => randomInt(1.5, 3)).toThrow(DisInvalidArgumentError); + }); +}); + +describe('random — fillRandom', () => { + it('fills the provided view in place', () => { + const buf = new Uint8Array(32); + const out = fillRandom(buf); + expect(out).toBe(buf); + expect(buf.some((b) => b !== 0)).toBe(true); + }); +}); diff --git a/src/signing/index.ts b/src/signing/index.ts new file mode 100644 index 0000000..036be0f --- /dev/null +++ b/src/signing/index.ts @@ -0,0 +1,96 @@ +/** + * dis-signing — asymmetric digital signatures. + * + * Primitive: ECDSA over the NIST P-256 curve with SHA-256, via WebCrypto. + * Public keys are exchanged as SPKI bytes; private keys are generated + * non-extractable so they never leave the device. Signatures are the raw + * `r || s` concatenation WebCrypto produces (fixed 64 bytes for P-256), which + * is the exact wire form Singra Vault's op-log device signatures use. + * + * Canonicalisation of the signed payload is the caller's responsibility — DIS + * signs and verifies opaque byte strings and never interprets their structure. + */ + +import { subtle } from '../core/provider.js'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +const ECDSA_P256_ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256' } as const; +const ECDSA_P256_PARAMS = { name: 'ECDSA', hash: 'SHA-256' } as const; + +/** Raw byte length of a P-256 ECDSA signature (`r || s`, 32 bytes each). */ +export const ECDSA_P256_SIGNATURE_LENGTH = 64; + +export interface EcdsaP256KeyPair { + readonly privateKey: CryptoKey; + readonly publicKey: CryptoKey; + /** SPKI-encoded public key bytes, ready to be base64url-encoded for storage. */ + readonly publicKeySpki: Uint8Array; +} + +/** + * Generates a fresh non-extractable ECDSA P-256 key pair and exports the + * public key as SPKI bytes. + */ +export async function generateEcdsaP256KeyPair(): Promise { + const keyPair = await subtle().generateKey( + ECDSA_P256_ALGORITHM, + /* extractable */ false, + ['sign', 'verify'], + ); + const spki = await subtle().exportKey('spki', keyPair.publicKey); + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicKeySpki: new Uint8Array(spki), + }; +} + +/** Imports an SPKI-encoded P-256 public key for signature verification. */ +export async function importEcdsaP256PublicKeySpki(spki: Uint8Array): Promise { + return subtle().importKey( + 'spki', + spki as BufferSource, + ECDSA_P256_ALGORITHM, + false, + ['verify'], + ); +} + +/** + * Signs `data` with an ECDSA P-256 private key, returning the raw 64-byte + * `r || s` signature. Throws {@link DisInvalidArgumentError} if WebCrypto + * returns an unexpected length (defends against curve/algorithm misuse). + */ +export async function signEcdsaP256(privateKey: CryptoKey, data: Uint8Array): Promise { + const signature = await subtle().sign(ECDSA_P256_PARAMS, privateKey, data as BufferSource); + const bytes = new Uint8Array(signature); + if (bytes.length !== ECDSA_P256_SIGNATURE_LENGTH) { + throw new DisInvalidArgumentError('unexpected ECDSA signature byte length'); + } + return bytes; +} + +/** + * Verifies a raw 64-byte `r || s` ECDSA P-256 signature over `data`. Returns + * `false` for an invalid signature; throws {@link DisInvalidArgumentError} if + * the signature is not the expected length. + */ +export async function verifyEcdsaP256( + publicKey: CryptoKey, + signature: Uint8Array, + data: Uint8Array, +): Promise { + if (signature.length !== ECDSA_P256_SIGNATURE_LENGTH) { + throw new DisInvalidArgumentError('unexpected ECDSA signature byte length'); + } + try { + return await subtle().verify( + ECDSA_P256_PARAMS, + publicKey, + signature as BufferSource, + data as BufferSource, + ); + } catch { + return false; + } +} diff --git a/src/signing/signing.test.ts b/src/signing/signing.test.ts new file mode 100644 index 0000000..e1dfbcf --- /dev/null +++ b/src/signing/signing.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + ECDSA_P256_SIGNATURE_LENGTH, + generateEcdsaP256KeyPair, + importEcdsaP256PublicKeySpki, + signEcdsaP256, + verifyEcdsaP256, +} from './index.js'; +import { utf8ToBytes } from '../core/encoding.js'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +describe('signing — ECDSA P-256', () => { + it('produces a fixed 64-byte raw r||s signature that verifies', async () => { + const { privateKey, publicKey } = await generateEcdsaP256KeyPair(); + const data = utf8ToBytes('vault-operation-canonical-body'); + const sig = await signEcdsaP256(privateKey, data); + expect(sig.length).toBe(ECDSA_P256_SIGNATURE_LENGTH); + expect(await verifyEcdsaP256(publicKey, sig, data)).toBe(true); + }); + + it('round-trips a public key through SPKI export/import', async () => { + const { privateKey, publicKeySpki } = await generateEcdsaP256KeyPair(); + const data = utf8ToBytes('payload'); + const sig = await signEcdsaP256(privateKey, data); + const imported = await importEcdsaP256PublicKeySpki(publicKeySpki); + expect(await verifyEcdsaP256(imported, sig, data)).toBe(true); + }); + + it('rejects a tampered payload', async () => { + const { privateKey, publicKey } = await generateEcdsaP256KeyPair(); + const sig = await signEcdsaP256(privateKey, utf8ToBytes('original')); + expect(await verifyEcdsaP256(publicKey, sig, utf8ToBytes('tampered'))).toBe(false); + }); + + it('rejects a signature from a different key', async () => { + const a = await generateEcdsaP256KeyPair(); + const b = await generateEcdsaP256KeyPair(); + const data = utf8ToBytes('payload'); + const sig = await signEcdsaP256(a.privateKey, data); + expect(await verifyEcdsaP256(b.publicKey, sig, data)).toBe(false); + }); + + it('throws on a wrong-length signature', async () => { + const { publicKey } = await generateEcdsaP256KeyPair(); + await expect(verifyEcdsaP256(publicKey, new Uint8Array(10), utf8ToBytes('x'))) + .rejects.toBeInstanceOf(DisInvalidArgumentError); + }); +}); diff --git a/src/totp/index.ts b/src/totp/index.ts new file mode 100644 index 0000000..71a61bd --- /dev/null +++ b/src/totp/index.ts @@ -0,0 +1,78 @@ +/** + * dis-totp — time-based one-time passwords (RFC 6238). + * + * Thin wrapper over the audited `otpauth` library so applications never embed + * the OTP primitive directly. Parameters are pinned to the values Singra Vault + * uses (SHA-1, 6 digits, 30-second period, 160-bit secrets) so existing + * enrolled authenticators keep working unchanged. + * + * SHA-1 here is the standardised HMAC inside the TOTP construction (RFC 6238), + * which every authenticator app implements; it is not used as a hash for any + * security decision elsewhere. + */ + +import * as OTPAuth from 'otpauth'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +/** TOTP parameters, fixed to remain compatible with enrolled authenticators. */ +export const TOTP_ALGORITHM = 'SHA1' as const; +export const TOTP_DIGITS = 6; +export const TOTP_PERIOD_SECONDS = 30; +/** Secret size in bytes (160-bit) as produced by {@link generateTotpSecret}. */ +export const TOTP_SECRET_SIZE = 20; + +export interface TotpParams { + /** Issuer label shown in the authenticator app. */ + readonly issuer: string; + /** Account label (typically the user's email). */ + readonly label: string; + /** Base32-encoded shared secret. */ + readonly secret: string; +} + +/** Generates a new base32-encoded 160-bit TOTP secret. */ +export function generateTotpSecret(): string { + return new OTPAuth.Secret({ size: TOTP_SECRET_SIZE }).base32; +} + +function buildTotp(params: TotpParams): OTPAuth.TOTP { + return new OTPAuth.TOTP({ + issuer: params.issuer, + label: params.label, + algorithm: TOTP_ALGORITHM, + digits: TOTP_DIGITS, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(params.secret.replace(/\s/g, '')), + }); +} + +/** Builds the `otpauth://` provisioning URI for a QR code. */ +export function buildTotpUri(params: TotpParams): string { + if (!params.secret) { + throw new DisInvalidArgumentError('TOTP secret is required'); + } + return buildTotp(params).toString(); +} + +/** + * Verifies a TOTP `code` against a base32 `secret`, allowing `window` periods + * of clock drift on either side (default 1 = ±30s). Returns `true` on a match. + * Malformed secrets/codes return `false` rather than throwing. + */ +export function verifyTotpCode( + secret: string, + code: string, + window = 1, +): boolean { + try { + const totp = new OTPAuth.TOTP({ + algorithm: TOTP_ALGORITHM, + digits: TOTP_DIGITS, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '')), + }); + return totp.validate({ token: code.replace(/\s/g, ''), window }) !== null; + } catch { + return false; + } +} diff --git a/src/totp/totp.test.ts b/src/totp/totp.test.ts new file mode 100644 index 0000000..f927499 --- /dev/null +++ b/src/totp/totp.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import * as OTPAuth from 'otpauth'; +import { + TOTP_DIGITS, + TOTP_PERIOD_SECONDS, + buildTotpUri, + generateTotpSecret, + verifyTotpCode, +} from './index.js'; + +describe('totp', () => { + it('generates a base32 160-bit secret', () => { + const secret = generateTotpSecret(); + expect(secret).toMatch(/^[A-Z2-7]+$/); + // 20 bytes -> 32 base32 chars (unpadded) + expect(secret.length).toBe(32); + }); + + it('builds an otpauth:// URI with the pinned parameters', () => { + const secret = generateTotpSecret(); + const uri = buildTotpUri({ issuer: 'Singra', label: 'user@example.com', secret }); + expect(uri).toContain('otpauth://totp/'); + expect(uri).toContain('algorithm=SHA1'); + expect(uri).toContain('digits=6'); + expect(uri).toContain('period=30'); + }); + + it('verifies a freshly generated code (interop with raw otpauth)', () => { + const secret = generateTotpSecret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: TOTP_DIGITS, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secret), + }); + const code = totp.generate(); + expect(verifyTotpCode(secret, code)).toBe(true); + expect(verifyTotpCode(secret, `${code} `)).toBe(true); // whitespace tolerated + }); + + it('rejects a wrong code and malformed secret without throwing', () => { + const secret = generateTotpSecret(); + expect(verifyTotpCode(secret, '000000')).toBe(false); + expect(verifyTotpCode('not-base32!!', '123456')).toBe(false); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 127c641..32886c5 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ 'post-quantum/index': 'src/post-quantum/index.ts', 'vault-crypto/index': 'src/vault-crypto/index.ts', 'integrity/index': 'src/integrity/index.ts', + 'signing/index': 'src/signing/index.ts', + 'totp/index': 'src/totp/index.ts', 'migrations/index': 'src/migrations/index.ts', }, format: ['esm'], @@ -31,5 +33,5 @@ export default defineConfig({ target: 'es2022', // Keep crypto libraries external so consumers control the exact version // and so the post-quantum dependency stays optional. - external: ['hash-wasm', '@noble/post-quantum'], + external: ['hash-wasm', '@noble/post-quantum', 'otpauth'], });