Phase 3a red: auth.unwrapAuth tests and stub

Tests for the password-only decryption chain that follows a successful
login (SRP or email OTP, with or without 2FA). The unwrap covers:
  password -> KEK (Argon2id) -> masterKey (secretbox) ->
  secretKey (secretbox) -> tokenBytes (sealed box) -> base64url token

Each test builds a synthetic AuthorizationResponse using libsodium
directly and asserts unwrapAuth recovers the inputs byte for byte. The
test file also functions as the canonical description of the protocol.

Adds src/auth/types.ts with KeyAttributes, SRPAttributes,
AuthorizationResponse, and LoginChallenge declarations matching the
README's API reference. src/auth/unwrap.ts is the throwing stub; the
real implementation lands next.
This commit is contained in:
2026-05-11 00:58:27 -07:00
parent 2e2238fa5f
commit 6386a0ec9f
3 changed files with 313 additions and 0 deletions

53
src/auth/types.ts Normal file
View File

@@ -0,0 +1,53 @@
// Base64-encoded binary, in either standard or URL-safe form. Decoded with
// crypto.fromBase64 which accepts either variant.
export type Base64 = string;
// The blob the server returns alongside the auth token after a successful
// login. Together with the user's password, this is everything quack needs
// to derive the master key, secret key, and auth token.
export interface KeyAttributes {
kekSalt: Base64;
encryptedKey: Base64;
keyDecryptionNonce: Base64;
publicKey: Base64;
encryptedSecretKey: Base64;
secretKeyDecryptionNonce: Base64;
memLimit: number;
opsLimit: number;
masterKeyEncryptedWithRecoveryKey?: Base64;
masterKeyDecryptionNonce?: Base64;
recoveryKeyEncryptedWithMasterKey?: Base64;
recoveryKeyDecryptionNonce?: Base64;
}
// Server-issued attributes needed to start an SRP-6a handshake. Returned
// from GET /users/srp/attributes?email=<email>.
export interface SRPAttributes {
srpUserID: string;
srpSalt: Base64;
memLimit: number;
opsLimit: number;
kekSalt: Base64;
isEmailMFAEnabled: boolean;
}
// The body of a successful login response. Exactly one of the following
// situations applies, and the caller dispatches on the populated fields:
// - both keyAttributes and encryptedToken present: login is complete
// - twoFactorSessionID present: caller must submit a TOTP code
// - passkeySessionID present: caller must complete a passkey ceremony
export interface AuthorizationResponse {
id: number;
keyAttributes?: KeyAttributes;
encryptedToken?: Base64;
twoFactorSessionID?: string;
passkeySessionID?: string;
}
// Discriminated union returned by the high-level login functions to tell
// the caller what to do next.
export type LoginChallenge =
| { kind: "complete"; response: AuthorizationResponse }
| { kind: "totp"; sessionID: string }
| { kind: "passkey"; sessionID: string }
| { kind: "emailOTP" };

17
src/auth/unwrap.ts Normal file
View File

@@ -0,0 +1,17 @@
// Stub: see the README "Development workflow" section for TDD policy.
import type { AuthorizationResponse } from "./types.js";
export interface UnwrapResult {
masterKey: Uint8Array;
secretKey: Uint8Array;
publicKey: Uint8Array;
token: string;
}
export const unwrapAuth = async (
_response: AuthorizationResponse,
_password: string,
): Promise<UnwrapResult> => {
throw new Error("auth.unwrapAuth not implemented");
};