@worker-tools/deno-kv-storage
Version:
An implementation of the StorageArea (1,2,3) interface for Deno with an extensible system for supporting various database backends.
314 lines (271 loc) • 7.83 kB
text/typescript
import { base64 } from "../deps.js";
/** Number of random bytes used to generate a nonce */
const defaultNonceSize = 16;
const text_encoder = new TextEncoder();
enum AuthenticationState {
Init,
ClientChallenge,
ServerChallenge,
ClientResponse,
ServerResponse,
Failed,
}
/**
* Collection of SCRAM authentication keys derived from a plaintext password
* in HMAC-derived binary format
*/
interface KeySignatures {
client: Uint8Array;
server: Uint8Array;
stored: Uint8Array;
}
/**
* Reason of authentication failure
*/
export enum Reason {
BadMessage = "server sent an ill-formed message",
BadServerNonce = "server sent an invalid nonce",
BadSalt = "server specified an invalid salt",
BadIterationCount = "server specified an invalid iteration count",
BadVerifier = "server sent a bad verifier",
Rejected = "rejected by server",
}
function assert(cond: unknown): asserts cond {
if (!cond) {
throw new Error("Scram protocol assertion failed");
}
}
// TODO
// Handle mapping and maybe unicode normalization.
// Add tests for invalid string values
/**
* Normalizes string per SASLprep.
* @see {@link https://tools.ietf.org/html/rfc3454}
* @see {@link https://tools.ietf.org/html/rfc4013}
*/
function assertValidScramString(str: string) {
const unsafe = /[^\x21-\x7e]/;
if (unsafe.test(str)) {
throw new Error(
"scram username/password is currently limited to safe ascii characters",
);
}
}
async function computeScramSignature(
message: string,
raw_key: Uint8Array,
): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
raw_key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return new Uint8Array(
await crypto.subtle.sign(
{ name: "HMAC", hash: "SHA-256" },
key,
text_encoder.encode(message),
),
);
}
function computeScramProof(signature: Uint8Array, key: Uint8Array): Uint8Array {
const digest = new Uint8Array(signature.length);
for (let i = 0; i < digest.length; i++) {
digest[i] = signature[i] ^ key[i];
}
return digest;
}
/**
* Derives authentication key signatures from a plaintext password
*/
async function deriveKeySignatures(
password: string,
salt: Uint8Array,
iterations: number,
): Promise<KeySignatures> {
const pbkdf2_password = await crypto.subtle.importKey(
"raw",
text_encoder.encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
hash: "SHA-256",
iterations,
name: "PBKDF2",
salt,
},
pbkdf2_password,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const client = new Uint8Array(
await crypto.subtle.sign("HMAC", key, text_encoder.encode("Client Key")),
);
const server = new Uint8Array(
await crypto.subtle.sign("HMAC", key, text_encoder.encode("Server Key")),
);
const stored = new Uint8Array(await crypto.subtle.digest("SHA-256", client));
return { client, server, stored };
}
/** Escapes "=" and "," in a string. */
function escape(str: string): string {
return str
.replace(/=/g, "=3D")
.replace(/,/g, "=2C");
}
function generateRandomNonce(size: number): string {
return base64.encode(crypto.getRandomValues(new Uint8Array(size)));
}
function parseScramAttributes(message: string): Record<string, string> {
const attrs: Record<string, string> = {};
for (const entry of message.split(",")) {
const pos = entry.indexOf("=");
if (pos < 1) {
throw new Error(Reason.BadMessage);
}
// TODO
// Replace with String.prototype.substring
const key = entry.substr(0, pos);
const value = entry.substr(pos + 1);
attrs[key] = value;
}
return attrs;
}
/**
* Client composes and verifies SCRAM authentication messages, keeping track
* of authentication #state and parameters.
* @see {@link https://tools.ietf.org/html/rfc5802}
*/
export class Client {
#auth_message: string;
#client_nonce: string;
#key_signatures?: KeySignatures;
#password: string;
#server_nonce?: string;
#state: AuthenticationState;
#username: string;
constructor(username: string, password: string, nonce?: string) {
assertValidScramString(password);
assertValidScramString(username);
this.#auth_message = "";
this.#client_nonce = nonce ?? generateRandomNonce(defaultNonceSize);
this.#password = password;
this.#state = AuthenticationState.Init;
this.#username = escape(username);
}
/**
* Composes client-first-message
*/
composeChallenge(): string {
assert(this.#state === AuthenticationState.Init);
try {
// "n" for no channel binding, then an empty authzid option follows.
const header = "n,,";
const challenge = `n=${this.#username},r=${this.#client_nonce}`;
const message = header + challenge;
this.#auth_message += challenge;
this.#state = AuthenticationState.ClientChallenge;
return message;
} catch (e) {
this.#state = AuthenticationState.Failed;
throw e;
}
}
/**
* Processes server-first-message
*/
async receiveChallenge(challenge: string) {
assert(this.#state === AuthenticationState.ClientChallenge);
try {
const attrs = parseScramAttributes(challenge);
const nonce = attrs.r;
if (!attrs.r || !attrs.r.startsWith(this.#client_nonce)) {
throw new Error(Reason.BadServerNonce);
}
this.#server_nonce = nonce;
let salt: Uint8Array | undefined;
if (!attrs.s) {
throw new Error(Reason.BadSalt);
}
try {
salt = base64.decode(attrs.s);
} catch {
throw new Error(Reason.BadSalt);
}
const iterCount = parseInt(attrs.i) | 0;
if (iterCount <= 0) {
throw new Error(Reason.BadIterationCount);
}
this.#key_signatures = await deriveKeySignatures(
this.#password,
salt,
iterCount,
);
this.#auth_message += "," + challenge;
this.#state = AuthenticationState.ServerChallenge;
} catch (e) {
this.#state = AuthenticationState.Failed;
throw e;
}
}
/**
* Composes client-final-message
*/
async composeResponse(): Promise<string> {
assert(this.#state === AuthenticationState.ServerChallenge);
assert(this.#key_signatures);
assert(this.#server_nonce);
try {
// "biws" is the base-64 encoded form of the gs2-header "n,,".
const responseWithoutProof = `c=biws,r=${this.#server_nonce}`;
this.#auth_message += "," + responseWithoutProof;
const proof = base64.encode(
computeScramProof(
await computeScramSignature(
this.#auth_message,
this.#key_signatures.stored,
),
this.#key_signatures.client,
),
);
const message = `${responseWithoutProof},p=${proof}`;
this.#state = AuthenticationState.ClientResponse;
return message;
} catch (e) {
this.#state = AuthenticationState.Failed;
throw e;
}
}
/**
* Processes server-final-message
*/
async receiveResponse(response: string) {
assert(this.#state === AuthenticationState.ClientResponse);
assert(this.#key_signatures);
try {
const attrs = parseScramAttributes(response);
if (attrs.e) {
throw new Error(attrs.e ?? Reason.Rejected);
}
const verifier = base64.encode(
await computeScramSignature(
this.#auth_message,
this.#key_signatures.server,
),
);
if (attrs.v !== verifier) {
throw new Error(Reason.BadVerifier);
}
this.#state = AuthenticationState.ServerResponse;
} catch (e) {
this.#state = AuthenticationState.Failed;
throw e;
}
}
}