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
27 changes: 26 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
52 changes: 52 additions & 0 deletions src/aead/aead-raw.test.ts
Original file line number Diff line number Diff line change
@@ -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')));
});
});
88 changes: 88 additions & 0 deletions src/aead/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CryptoKey> {
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<CryptoKey> {
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<Uint8Array> {
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<Uint8Array> {
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,
Expand Down
30 changes: 30 additions & 0 deletions src/core/encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 38 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
66 changes: 63 additions & 3 deletions src/integrity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> {
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<string> {
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<string> {
return bytesToBase64Url(await sha256Bytes(data));
}

/** SHA-256 of raw bytes, lower-case hex-encoded. */
export async function sha256Hex(data: Uint8Array): Promise<string> {
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<string> {
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<CryptoKey> {
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<Uint8Array> {
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<Uint8Array> {
const key = await importHmacSha256Key(keyBytes, ['sign']);
return hmacSha256WithKey(key, data);
}

/** SHA-256 of a UTF-8 string, base64-encoded. */
Expand Down
Loading
Loading