All files / challenge.ts

100.00% Branches 26/26
100.00% Functions 5/5
100.00% Lines 55/55
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
x5
x5
x5
 
x5
x5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x5
x5
 
x37
x37
x37
 
x16
x16
x15
x16
x15
x16
 
 
x5
x5
x5
 
x21
x21
x21
 
 
 
 
 
x5
x5
x5
x5
x5
 
x19
x19
x2
x2
x17
 
x17
x17
x17
x19
x1
x1
x19
x3
x3
 
x13
x13
 
x19
x19
x1
x1
x19
x2
x2
x9
x19
 
 
x5
x9
x9



























































































import { hmac } from "@noble/hashes/hmac.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { decodeBase64Url, encodeBase64Url } from "@std/encoding/base64url";

const enc = new TextEncoder();
const dec = new TextDecoder();

/** The proof method a challenge asks for. */
export type ProofMethod = "sign" | "decrypt";

/**
 * The self-describing, server-issued challenge. Serialized as `b64url(json).b64url(hmac)`
 * so it is stateless: the Issuer re-verifies its own HMAC on redemption.
 */
export interface Challenge {
  v: 1;
  method: ProofMethod;
  /** Requested audience, bound into the challenge (and later the Token). */
  aud: string;
  /** The public key string this challenge is bound to (ssh-ed25519 line or age1…). */
  key: string;
  /** Issued-at, unix seconds. */
  iat: number;
  /** Random, for uniqueness. */
  nonce: string;
}

/** Default freshness window (seconds) — also the signature/secret replay window. */
export const MAX_AGE_SECONDS = 60;

function mac(payload: string, secret: Uint8Array): Uint8Array {
  return hmac(sha256, secret, enc.encode(payload));
}

function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

/** Issue a challenge token, MAC'd with the server secret. */
export function issueChallenge(
  challenge: Challenge,
  secret: Uint8Array,
): string {
  const payload = encodeBase64Url(enc.encode(JSON.stringify(challenge)));
  return `${payload}.${encodeBase64Url(mac(payload, secret))}`;
}

/**
 * Verify a challenge token's MAC and freshness, returning the decoded challenge.
 * Throws on a bad MAC, malformed token, or an `iat` outside ±`maxAgeSeconds` of `nowSeconds`.
 */
export function verifyChallenge(
  token: string,
  secret: Uint8Array,
  nowSeconds: number,
  maxAgeSeconds: number = MAX_AGE_SECONDS,
): Challenge {
  const dot = token.indexOf(".");
  if (dot < 1 || dot === token.length - 1) {
    throw new Error("malformed challenge");
  }
  const payload = token.slice(0, dot);

  let provided: Uint8Array;
  try {
    provided = decodeBase64Url(token.slice(dot + 1));
  } catch {
    throw new Error("malformed challenge MAC");
  }
  if (!timingSafeEqual(provided, mac(payload, secret))) {
    throw new Error("bad challenge MAC");
  }

  const challenge = JSON.parse(
    dec.decode(decodeBase64Url(payload)),
  ) as Challenge;
  if (typeof challenge.iat !== "number") throw new Error("bad challenge iat");
  if (challenge.iat > nowSeconds + maxAgeSeconds) {
    throw new Error("challenge from the future");
  }
  if (challenge.iat < nowSeconds - maxAgeSeconds) {
    throw new Error("challenge expired");
  }
  return challenge;
}

/** A random nonce for a fresh challenge. */
export function randomNonce(): string {
  return encodeBase64Url(crypto.getRandomValues(new Uint8Array(16)));
}