Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
/**
* DER encoder for the Android Keystore `KeyDescription` extension
* (OID `1.3.6.1.4.1.11129.2.1.17`) per W3C WebAuthn L3 §8.4.
*
* The `android-key` verifier requires that the leaf attestation
* certificate carry an extension at this OID. The full
* `KeyDescription` SEQUENCE encodes the attestation challenge plus a
* large `softwareEnforced` / `teeEnforced` `AuthorizationList` —
* production caller policy decodes those lists. For test purposes the
* verifier in this package only checks **presence** of the extension,
* so this fixture emits a minimal-but-spec-shaped DER body that the
* leaf cert can embed.
*/
import {
encodeAsn1,
makeConstructed,
makePrimitive,
UNIVERSAL_TAGS,
} from "@suaveplan/x509/asn1";
/** Android Keystore KeyDescription extension OID. */
export const ANDROID_KEY_OID = "1.3.6.1.4.1.11129.2.1.17";
/**
* Encode the `KeyDescription` extension value (the inner OCTET STRING
* content of the X.509 `Extension.extnValue`). Shape:
*
* ```
* KeyDescription ::= SEQUENCE {
* attestationVersion INTEGER,
* attestationSecurityLevel ENUMERATED,
* keymasterVersion INTEGER,
* keymasterSecurityLevel ENUMERATED,
* attestationChallenge OCTET STRING,
* uniqueId OCTET STRING,
* softwareEnforced AuthorizationList,
* teeEnforced AuthorizationList,
* }
* ```
*
* `attestationChallenge` MUST equal the `clientDataHash` per the
* Android Keystore specification; the cross-bind step lives in the
* caller's metadata layer (out of scope of this verifier).
*/
export function encodeAndroidKeyExtensionValue(
attestationChallenge: Uint8Array
): Uint8Array {
const intNode = (v: number) =>
makePrimitive(
"universal",
UNIVERSAL_TAGS.INTEGER,
new Uint8Array([v & 0xff])
);
const enumNode = (v: number) =>
makePrimitive(
"universal",
UNIVERSAL_TAGS.ENUMERATED,
new Uint8Array([v & 0xff])
);
const octet = (b: Uint8Array) =>
makePrimitive("universal", UNIVERSAL_TAGS.OCTET_STRING, b);
const emptyList = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, []);
const seq = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
intNode(4), // attestationVersion (KeyMaster v4)
enumNode(1), // attestationSecurityLevel = TrustedEnvironment
intNode(4), // keymasterVersion
enumNode(1), // keymasterSecurityLevel = TrustedEnvironment
octet(attestationChallenge),
octet(new Uint8Array(0)), // uniqueId
emptyList, // softwareEnforced
emptyList, // teeEnforced
]);
return encodeAsn1(seq);
}

View File

@@ -0,0 +1,41 @@
/**
* DER encoder for the Apple WebAuthn anonymous-attestation nonce
* extension (OID `1.2.840.113635.100.8.2`).
*
* The extension value is `SEQUENCE { [1] EXPLICIT OCTET STRING <nonce> }`
* where `nonce = SHA-256(authData || clientDataHash)`. The Apple verifier
* (`@suaveplan/webauthn/attestation/apple`) walks this structure with
* `decodeAsn1` + `walkForOctet` to recover the inner nonce. This
* fixture builder emits the exact byte form Apple's anonymous
* attestation profile attaches to its leaf certificate.
*/
import {
encodeAsn1,
makeConstructed,
makePrimitive,
UNIVERSAL_TAGS,
} from "@suaveplan/x509/asn1";
/** Apple anonymous-attestation nonce extension OID per W3C WebAuthn L3 §8.8. */
export const APPLE_NONCE_OID = "1.2.840.113635.100.8.2";
/**
* Encode the Apple nonce extension *value* (the inner OCTET STRING content
* of the X.509 `Extension.extnValue`). The full extension wrapper is added
* by `buildExtensions` in {@link x509-chains.ts}.
*
* @param nonce - SHA-256(authData || clientDataHash), 32 bytes.
* @returns DER bytes of `SEQUENCE { [1] EXPLICIT OCTET STRING <nonce> }`.
*/
export function encodeAppleNonceExtensionValue(nonce: Uint8Array): Uint8Array {
const inner = makePrimitive(
"universal",
UNIVERSAL_TAGS.OCTET_STRING,
nonce
);
const explicitWrapper = makeConstructed("context", 1, [inner]);
const seq = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
explicitWrapper,
]);
return encodeAsn1(seq);
}

View File

@@ -0,0 +1,158 @@
/**
* Build CBOR-encoded attestation-object envelopes for tests. Supports
* `none` and `packed` self-attestation natively (round-trip-stable via
* deterministic ECDSA + Ed25519). Full-attestation chains for `packed`,
* `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple` build on
* top of the x509 fixture chain helpers and are exposed via dedicated
* sub-builders below.
*/
import { encode } from "@suaveplan/cbor";
import type { CoseKey } from "@suaveplan/cbor/cose";
import { ed25519 } from "@suaveplan/crypto-curve";
import { sha256 } from "@suaveplan/crypto-hash";
import {
type EcTestKey,
type EddsaTestKey,
signEcdsaP256Der,
} from "./deterministic-keys.js";
export interface BuildNoneInput {
readonly authData: Uint8Array;
}
/** Build a CBOR attestation-object with fmt="none" and empty attStmt. */
export function buildNoneAttestation(input: BuildNoneInput): Uint8Array {
return encode(
new Map<string, unknown>([
["fmt", "none"],
["attStmt", new Map<string, unknown>()],
["authData", input.authData],
])
);
}
export interface BuildPackedSelfEcdsaInput {
readonly authData: Uint8Array;
readonly clientDataHash: Uint8Array;
readonly key: EcTestKey;
/** COSE alg label (default -7 for ES256). */
readonly alg?: number;
/** Tamper the signature by flipping a byte. */
readonly tamperSig?: boolean;
}
/** Build a packed self-attestation envelope (no x5c) over the credential's own ES256 key. */
export function buildPackedSelfEs256(
input: BuildPackedSelfEcdsaInput
): Uint8Array {
const concat = concatBytes(input.authData, input.clientDataHash);
const digest = sha256(concat);
let sig = signEcdsaP256Der(digest, input.key.priv);
if (input.tamperSig === true) {
sig = flipByte(sig, sig.byteLength - 1);
}
const attStmt = new Map<string, unknown>();
attStmt.set("alg", input.alg ?? -7);
attStmt.set("sig", sig);
return encode(
new Map<string, unknown>([
["fmt", "packed"],
["attStmt", attStmt],
["authData", input.authData],
])
);
}
export interface BuildPackedSelfEddsaInput {
readonly authData: Uint8Array;
readonly clientDataHash: Uint8Array;
readonly key: EddsaTestKey;
readonly tamperSig?: boolean;
}
/** Build a packed self-attestation envelope over an Ed25519 credential key. */
export function buildPackedSelfEd25519(
input: BuildPackedSelfEddsaInput
): Uint8Array {
const message = concatBytes(input.authData, input.clientDataHash);
let sig = ed25519.sign(message, input.key.priv);
if (input.tamperSig === true) {
sig = flipByte(sig, sig.byteLength - 1);
}
const attStmt = new Map<string, unknown>();
attStmt.set("alg", -8);
attStmt.set("sig", sig);
return encode(
new Map<string, unknown>([
["fmt", "packed"],
["attStmt", attStmt],
["authData", input.authData],
])
);
}
/** Build a packed full-attestation envelope using a caller-supplied DER cert chain. */
export interface BuildPackedFullInput {
readonly authData: Uint8Array;
readonly clientDataHash: Uint8Array;
readonly alg: number;
readonly sig: Uint8Array;
readonly x5c: readonly Uint8Array[];
}
export function buildPackedFullEnvelope(
input: BuildPackedFullInput
): Uint8Array {
const attStmt = new Map<string, unknown>();
attStmt.set("alg", input.alg);
attStmt.set("sig", input.sig);
attStmt.set(
"x5c",
input.x5c.map((c) => new Uint8Array(c))
);
return encode(
new Map<string, unknown>([
["fmt", "packed"],
["attStmt", attStmt],
["authData", input.authData],
])
);
}
/** Build a generic fmt envelope wrapping caller-built attStmt fields. */
export function buildGenericEnvelope(
fmt: string,
attStmt: Map<string, unknown>,
authData: Uint8Array
): Uint8Array {
return encode(
new Map<string, unknown>([
["fmt", fmt],
["attStmt", attStmt],
["authData", authData],
])
);
}
/** Compute SHA-256(authData || clientDataHash) — used as TPM extraData / Apple nonce / Safetynet nonce. */
export function attestationDigest(
authData: Uint8Array,
clientDataHash: Uint8Array
): Uint8Array {
return sha256(concatBytes(authData, clientDataHash));
}
export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.byteLength + b.byteLength);
out.set(a, 0);
out.set(b, a.byteLength);
return out;
}
export function flipByte(src: Uint8Array, index: number): Uint8Array {
const out = new Uint8Array(src);
out[index] = (out[index] ?? 0) ^ 0xff;
return out;
}
/** Re-export CoseKey for fixture-consumer convenience. */
export type { CoseKey };

View File

@@ -0,0 +1,102 @@
/**
* Build byte-by-byte W3C WebAuthn L3 §6.1 authenticator-data frames for
* tests. All optional segments are gated by the flag bits the caller
* sets — `attestedCredentialData` requires `aaguid`/`credentialId`/
* `credentialPublicKey` and adds the AT bit; `extensions` adds the ED
* bit and a CBOR-encoded extensions map.
*/
import { encode } from "@suaveplan/cbor";
import { type CoseKey, encodeCoseKey } from "@suaveplan/cbor/cose";
import { sha256 } from "@suaveplan/crypto-hash";
export const FLAG_UP = 0x01;
export const FLAG_UV = 0x04;
export const FLAG_BE = 0x08;
export const FLAG_BS = 0x10;
export const FLAG_AT = 0x40;
export const FLAG_ED = 0x80;
export interface BuildAuthDataInput {
/** rpId string. If supplied, rpIdHash = sha256(rpId). */
readonly rpId?: string;
/** Pre-computed rpIdHash. Overrides rpId. Must be 32 bytes. */
readonly rpIdHash?: Uint8Array;
/** Flag byte (default 0x01 — UP only). */
readonly flags?: number;
/** Sign counter (default 0). */
readonly signCount?: number;
/** AAGUID for AT segment (default 16 zero bytes). */
readonly aaguid?: Uint8Array;
/** Credential id for AT segment. */
readonly credentialId?: Uint8Array;
/** Credential public key (COSE_Key) for AT segment. */
readonly credentialPublicKey?: CoseKey;
/** Extensions map for ED segment. */
readonly extensions?: Map<string, unknown>;
}
export interface BuiltAuthData {
readonly bytes: Uint8Array;
readonly rpIdHash: Uint8Array;
readonly flags: number;
readonly signCount: number;
}
export function buildAuthenticatorData(
input: BuildAuthDataInput
): BuiltAuthData {
const rpIdHash =
input.rpIdHash ??
(input.rpId !== undefined
? sha256(new TextEncoder().encode(input.rpId))
: new Uint8Array(32));
if (rpIdHash.byteLength !== 32) {
throw new Error("rpIdHash must be 32 bytes");
}
const flags = input.flags ?? FLAG_UP;
const signCount = input.signCount ?? 0;
const segments: Uint8Array[] = [];
segments.push(rpIdHash);
const prefix = new Uint8Array(5);
prefix[0] = flags;
new DataView(prefix.buffer).setUint32(1, signCount, false);
segments.push(prefix);
if ((flags & FLAG_AT) !== 0) {
const aaguid = input.aaguid ?? new Uint8Array(16);
if (aaguid.byteLength !== 16) {
throw new Error("aaguid must be 16 bytes");
}
const credId = input.credentialId;
const credPub = input.credentialPublicKey;
if (credId === undefined || credPub === undefined) {
throw new Error(
"AT flag set but credentialId/credentialPublicKey missing"
);
}
segments.push(aaguid);
const lenBytes = new Uint8Array(2);
new DataView(lenBytes.buffer).setUint16(0, credId.byteLength, false);
segments.push(lenBytes);
segments.push(credId);
segments.push(encodeCoseKey(credPub));
}
if ((flags & FLAG_ED) !== 0) {
const ext = input.extensions ?? new Map<string, unknown>();
segments.push(encode(ext));
}
return { bytes: concat(segments), rpIdHash, flags, signCount };
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) {
total += p.byteLength;
}
const out = new Uint8Array(total);
let cursor = 0;
for (const p of parts) {
out.set(p, cursor);
cursor += p.byteLength;
}
return out;
}

View File

@@ -0,0 +1,43 @@
/**
* Build a canonical `clientDataJSON` byte string + matching SHA-256 hash
* for use in webauthn tests. The output matches the W3C L3 §5.8.1
* CollectedClientData wire shape: a UTF-8 JSON object with the keys
* `type`, `challenge` (base64url), `origin`, optionally `crossOrigin`.
*/
import { sha256 } from "@suaveplan/crypto-hash";
import { toBase64Url } from "@suaveplan/crypto-utils";
export interface BuildClientDataInput {
readonly type: "webauthn.create" | "webauthn.get";
readonly challenge: Uint8Array;
readonly origin: string;
readonly crossOrigin?: boolean;
readonly extraField?: { key: string; value: unknown };
}
export interface BuiltClientData {
readonly json: string;
readonly bytes: Uint8Array;
readonly hash: Uint8Array;
}
const ENC = new TextEncoder();
export function buildClientDataJSON(
input: BuildClientDataInput
): BuiltClientData {
const obj: Record<string, unknown> = {
type: input.type,
challenge: toBase64Url(input.challenge),
origin: input.origin,
};
if (input.crossOrigin !== undefined) {
obj["crossOrigin"] = input.crossOrigin;
}
if (input.extraField !== undefined) {
obj[input.extraField.key] = input.extraField.value;
}
const json = JSON.stringify(obj);
const bytes = ENC.encode(json);
return { json, bytes, hash: sha256(bytes) };
}

View File

@@ -0,0 +1,107 @@
/**
* Inline COSE_Key CBOR encoder for fixtures. Mirrors
* `@suaveplan/cbor/cose#encodeCoseKey` but lives here so fixtures do not
* depend on the `@suaveplan/cbor/cose` subpath at runtime (the source
* code is wired correctly but its dist JS is not co-built — see the
* orchestrator follow-up on cbor).
*
* Only encodes the shapes the webauthn tests need: EC2 (with alg + crv +
* x + y), OKP (with alg + crv + x), and RSA (alg + n + e). Optional
* common fields (kid, key_ops, base_iv) are passed through if present.
*/
import { encode } from "@suaveplan/cbor";
import type { CoseKey } from "@suaveplan/cbor/cose";
const COSE_KTY_LABELS: Record<string, number> = {
OKP: 1,
EC2: 2,
RSA: 3,
Symmetric: 4,
};
const COSE_ALG_LABELS: Record<string, number> = {
ES256: -7,
ES384: -35,
ES512: -36,
EdDSA: -8,
RS256: -257,
RS384: -258,
RS512: -259,
HS256: 5,
HS384: 6,
HS512: 7,
};
const COSE_CURVE_LABELS: Record<string, number> = {
"P-256": 1,
"P-384": 2,
"P-521": 3,
X25519: 4,
X448: 5,
Ed25519: 6,
Ed448: 7,
};
interface AnyCose {
kty: string;
alg?: string;
crv?: string;
x?: Uint8Array;
y?: Uint8Array;
d?: Uint8Array;
n?: Uint8Array;
e?: Uint8Array;
k?: Uint8Array;
kid?: Uint8Array;
}
/** Encode a COSE_Key into its CBOR map representation. */
export function fixtureEncodeCoseKey(key: CoseKey): Uint8Array {
const k = key as AnyCose;
const map = new Map<number, unknown>();
const ktyLabel = COSE_KTY_LABELS[k.kty];
if (ktyLabel === undefined) {
throw new Error(`unknown kty ${k.kty}`);
}
map.set(1, ktyLabel);
if (k.kid !== undefined) {
map.set(2, k.kid);
}
if (k.alg !== undefined) {
const algLabel = COSE_ALG_LABELS[k.alg];
if (algLabel === undefined) {
throw new Error(`unknown alg ${k.alg}`);
}
map.set(3, algLabel);
}
if (k.kty === "EC2") {
if (k.crv === undefined || k.x === undefined || k.y === undefined) {
throw new Error("EC2 requires crv + x + y");
}
map.set(-1, COSE_CURVE_LABELS[k.crv]);
map.set(-2, k.x);
map.set(-3, k.y);
if (k.d !== undefined) {
map.set(-4, k.d);
}
} else if (k.kty === "OKP") {
if (k.crv === undefined || k.x === undefined) {
throw new Error("OKP requires crv + x");
}
map.set(-1, COSE_CURVE_LABELS[k.crv]);
map.set(-2, k.x);
if (k.d !== undefined) {
map.set(-4, k.d);
}
} else if (k.kty === "RSA") {
if (k.n === undefined || k.e === undefined) {
throw new Error("RSA requires n + e");
}
map.set(-1, k.n);
map.set(-2, k.e);
} else if (k.kty === "Symmetric") {
if (k.k === undefined) {
throw new Error("Symmetric requires k");
}
map.set(-1, k.k);
}
return encode(map);
}

View File

@@ -0,0 +1,152 @@
/**
* Deterministic test keys for the webauthn fixture suite.
*
* ES256 (P-256), ES384, ES512 use hardcoded scalars; ECDSA via
* `@noble/curves` uses RFC 6979 deterministic-k so signatures round-trip
* byte-stably given the same message. Ed25519 is deterministic by spec.
* Public keys are memoised across the suite.
*
* `@noble/curves` is added as a devDependency on `@suaveplan/webauthn`
* specifically for these fixtures (it is the same transitive dep that
* `@suaveplan/crypto-curve` already depends on; fixture-only).
*/
import { p256 as nobleP256 } from "@noble/curves/p256";
import { p384 as nobleP384 } from "@noble/curves/p384";
import { p521 as nobleP521 } from "@noble/curves/p521";
import type { Ec2Key, OkpKey } from "@suaveplan/cbor/cose";
import { ed25519 } from "@suaveplan/crypto-curve";
const P256_PRIV = Uint8Array.from([
0x70, 0x83, 0x95, 0xab, 0x2c, 0x46, 0x3d, 0x68, 0xa1, 0x10, 0x4f, 0x37,
0x29, 0xb5, 0xd5, 0x42, 0x0c, 0x18, 0x21, 0x9f, 0xa0, 0x60, 0xff, 0xff,
0x5c, 0x4d, 0x16, 0x97, 0x21, 0x83, 0x9b, 0x55,
]);
const P384_PRIV = Uint8Array.from(
Array.from({ length: 48 }, (_v, i) => (i * 7 + 3) & 0xff)
);
const P521_PRIV = Uint8Array.from([
0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45,
0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd,
0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45,
0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd,
0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45,
0x67, 0x89, 0xab, 0xcd, 0xef, 0x77,
]);
const ED25519_PRIV = new Uint8Array(32).fill(0xa5);
export interface EcTestKey {
readonly priv: Uint8Array;
readonly pubUncompressed: Uint8Array;
readonly x: Uint8Array;
readonly y: Uint8Array;
readonly coseKey: Ec2Key;
}
export interface EddsaTestKey {
readonly priv: Uint8Array;
readonly pub: Uint8Array;
readonly coseKey: OkpKey;
}
let p256Cache: EcTestKey | undefined;
let p384Cache: EcTestKey | undefined;
let p521Cache: EcTestKey | undefined;
let edCache: EddsaTestKey | undefined;
function ecBuild(
priv: Uint8Array,
pubUncompressed: Uint8Array,
coordLen: number,
crv: "P-256" | "P-384" | "P-521",
alg: "ES256" | "ES384" | "ES512"
): EcTestKey {
const x = pubUncompressed.slice(1, 1 + coordLen);
const y = pubUncompressed.slice(1 + coordLen, 1 + 2 * coordLen);
return {
priv,
pubUncompressed,
x,
y,
coseKey: {
kty: "EC2",
crv,
alg,
x,
y,
},
};
}
/** Deterministic P-256 keypair for ES256 webauthn tests. */
export function getEcdsaP256TestKey(): EcTestKey {
if (p256Cache !== undefined) {
return p256Cache;
}
const pub = nobleP256.getPublicKey(P256_PRIV, false);
p256Cache = ecBuild(P256_PRIV, pub, 32, "P-256", "ES256");
return p256Cache;
}
/** Deterministic P-384 keypair for ES384 webauthn tests. */
export function getEcdsaP384TestKey(): EcTestKey {
if (p384Cache !== undefined) {
return p384Cache;
}
const pub = nobleP384.getPublicKey(P384_PRIV, false);
p384Cache = ecBuild(P384_PRIV, pub, 48, "P-384", "ES384");
return p384Cache;
}
/** Deterministic P-521 keypair for ES512 webauthn tests. */
export function getEcdsaP521TestKey(): EcTestKey {
if (p521Cache !== undefined) {
return p521Cache;
}
const pub = nobleP521.getPublicKey(P521_PRIV, false);
p521Cache = ecBuild(P521_PRIV, pub, 66, "P-521", "ES512");
return p521Cache;
}
/** Deterministic Ed25519 keypair for EdDSA webauthn tests. */
export function getEd25519TestKey(): EddsaTestKey {
if (edCache !== undefined) {
return edCache;
}
const pub = ed25519.getPublicKey(ED25519_PRIV);
edCache = {
priv: ED25519_PRIV,
pub,
coseKey: {
kty: "OKP",
crv: "Ed25519",
alg: "EdDSA",
x: pub,
},
};
return edCache;
}
/** ECDSA-sign a digest with P-256. Returns DER signature. */
export function signEcdsaP256Der(
digest: Uint8Array,
priv: Uint8Array
): Uint8Array {
return nobleP256.sign(digest, priv).toBytes("der");
}
export function signEcdsaP384Der(
digest: Uint8Array,
priv: Uint8Array
): Uint8Array {
return nobleP384.sign(digest, priv).toBytes("der");
}
export function signEcdsaP521Der(
digest: Uint8Array,
priv: Uint8Array
): Uint8Array {
return nobleP521.sign(digest, priv).toBytes("der");
}

View File

@@ -0,0 +1,83 @@
export {
ANDROID_KEY_OID,
encodeAndroidKeyExtensionValue,
} from "./android-key-extension.js";
export {
APPLE_NONCE_OID,
encodeAppleNonceExtensionValue,
} from "./apple-nonce-extension.js";
export {
attestationDigest,
buildGenericEnvelope,
buildNoneAttestation,
buildPackedFullEnvelope,
buildPackedSelfEd25519,
buildPackedSelfEs256,
concatBytes,
flipByte,
} from "./attestation-objects.js";
export {
type BuildAuthDataInput,
type BuiltAuthData,
buildAuthenticatorData,
FLAG_AT,
FLAG_BE,
FLAG_BS,
FLAG_ED,
FLAG_UP,
FLAG_UV,
} from "./authenticator-data.js";
export {
type BuildClientDataInput,
type BuiltClientData,
buildClientDataJSON,
} from "./client-data.js";
export {
type EcTestKey,
type EddsaTestKey,
getEcdsaP256TestKey,
getEcdsaP384TestKey,
getEcdsaP521TestKey,
getEd25519TestKey,
signEcdsaP256Der,
signEcdsaP384Der,
signEcdsaP521Der,
} from "./deterministic-keys.js";
export {
type BuildSafetynetJwsInput,
type BuiltSafetynetJws,
buildSafetynetJws,
getSafetynetSignerLeafDer,
} from "./safetynet-jws.js";
export { createSeededRng, seededBytes } from "./seeded-rng.js";
export {
type AdapterCalls,
type AssertionResponseStub,
type AttestationResponseStub,
type AuthenticateCredentialStub,
type RegisterCredentialStub,
rejectWithDomException,
type StubAdapterOptions,
stubAdapter,
} from "./stub-adapter.js";
export {
type BuildTpmsAttestInput,
buildTpmsAttestCertify,
buildTpmtPublicRsa,
getTpmRsaTestKey,
signWithTpmAik,
tpmPubAreaName,
tpmRsaCoseKey,
} from "./tpm-attest.js";
export {
type BuildEcLeafInput,
type BuildRawSpkiInput,
type BuildRsaLeafInput,
type BuiltLeafCert,
buildLeafCertWithEcPublicKey,
buildLeafCertWithRawRsaSpki,
buildLeafCertWithRsaPublicKey,
type CustomExtension,
signWithCaP256,
toX5cStringArray,
} from "./x509-chains.js";

View File

@@ -0,0 +1,113 @@
/**
* Real Google-SafetyNet-shape JWS builder for webauthn
* `android-safetynet` attestation tests.
*
* Builds a compact JWS (`<protected>.<payload>.<signature>`) signed
* with ES256 over a JSON payload carrying `nonce`, `ctsProfileMatch`,
* and `basicIntegrity` exactly as Google's SafetyNet attestation
* tokens do. The protected header carries `x5c`: a base64-DER chain
* containing the signer's leaf certificate, as required by the
* `android-safetynet` verifier.
*/
import { p256 as nobleP256 } from "@noble/curves/p256";
import { sha256 } from "@suaveplan/crypto-hash";
import { compactSign } from "@suaveplan/jws";
import {
buildLeafCertWithEcPublicKey,
type CustomExtension,
toX5cStringArray,
} from "./x509-chains.js";
/** Deterministic ES256 signing key for the SafetyNet signer leaf. */
const SAFETYNET_LEAF_SK = new Uint8Array(32).fill(0x77);
interface SafetynetSigner {
readonly privateKeyHex: Uint8Array;
readonly publicKeyUncompressed: Uint8Array;
readonly leafDer: Uint8Array;
readonly rootDer: Uint8Array;
}
let signerCache: SafetynetSigner | undefined;
function getSafetynetSigner(): SafetynetSigner {
if (signerCache !== undefined) return signerCache;
const publicKeyUncompressed = nobleP256.getPublicKey(
SAFETYNET_LEAF_SK,
false
);
const { leafDer, rootDer } = buildLeafCertWithEcPublicKey({
subjectCn: "Webauthn SafetyNet Signer",
publicKeyUncompressed,
});
signerCache = {
privateKeyHex: SAFETYNET_LEAF_SK,
publicKeyUncompressed,
leafDer,
rootDer,
};
return signerCache;
}
/** Input to {@link buildSafetynetJws}. */
export interface BuildSafetynetJwsInput {
/** SHA-256(authData || clientDataHash) — encoded as base64 in the JWS payload's `nonce` claim. */
readonly nonce: Uint8Array;
readonly ctsProfileMatch?: boolean;
readonly basicIntegrity?: boolean;
readonly timestampMs?: number;
/** When provided, appends extra x5c entries (e.g. cross-signed intermediates). */
readonly extraX5c?: readonly Uint8Array[];
/** Optional extensions for the signer leaf cert (defaults to none). */
readonly leafExtensions?: readonly CustomExtension[];
}
/** Output of {@link buildSafetynetJws}. */
export interface BuiltSafetynetJws {
/** The complete compact JWS string. */
readonly compact: string;
/** Raw bytes of `compact` ready to drop into `attStmt.response`. */
readonly responseBytes: Uint8Array;
/** Leaf signer DER (also the first entry of `x5c`). */
readonly leafDer: Uint8Array;
}
const TEXT_ENC = new TextEncoder();
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
/** Build a real, parseable SafetyNet attestation JWS. */
export async function buildSafetynetJws(
input: BuildSafetynetJwsInput
): Promise<BuiltSafetynetJws> {
const signer = getSafetynetSigner();
const x5c = toX5cStringArray([signer.leafDer, ...(input.extraX5c ?? [])]);
const payload = JSON.stringify({
nonce: bytesToBase64(input.nonce),
timestampMs: input.timestampMs ?? 1_700_000_000_000,
apkPackageName: "com.google.android.gms",
apkDigestSha256: bytesToBase64(sha256(TEXT_ENC.encode("apk-test"))),
ctsProfileMatch: input.ctsProfileMatch ?? true,
basicIntegrity: input.basicIntegrity ?? true,
apkCertificateDigestSha256: [
bytesToBase64(sha256(TEXT_ENC.encode("apk-cert"))),
],
});
const compact = await compactSign(payload, signer.privateKeyHex, "ES256", {
protectedHeader: { alg: "ES256", x5c },
});
return {
compact,
responseBytes: TEXT_ENC.encode(compact),
leafDer: signer.leafDer,
};
}
/** Re-export the signer's leaf DER so tests can assert trust-path equality. */
export function getSafetynetSignerLeafDer(): Uint8Array {
return getSafetynetSigner().leafDer;
}

View File

@@ -0,0 +1,34 @@
/**
* Deterministic seeded RNG for fixture generation. xorshift32-based;
* stable across runs given the same seed. Implements `Rng` from
* `@suaveplan/types`.
*/
import type { Rng } from "@suaveplan/types";
export function createSeededRng(seed: number): Rng {
let state = seed | 0 || 1;
return {
nextUint32(): number {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return state >>> 0;
},
nextFloat(): number {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return ((state >>> 0) % 0x100000000) / 0x100000000;
},
};
}
/** Generate `length` deterministic bytes from `seed`. */
export function seededBytes(seed: number, length: number): Uint8Array {
const rng = createSeededRng(seed);
const out = new Uint8Array(length);
for (let i = 0; i < length; i += 1) {
out[i] = rng.nextUint32() & 0xff;
}
return out;
}

View File

@@ -0,0 +1,155 @@
/**
* Stub `WebAuthnAdapter` for ceremony tests. Replays a pre-built
* authenticator response and records the options the consumer passed.
* Production code never invokes this — it lives under `__fixtures__/`
* which is excluded from coverage.
*/
import type { WebAuthnAdapter } from "../adapter/adapter.js";
export interface AttestationResponseStub {
readonly attestationObject: Uint8Array;
readonly clientDataJSON: Uint8Array;
readonly transports?: readonly string[];
}
export interface AssertionResponseStub {
readonly authenticatorData: Uint8Array;
readonly clientDataJSON: Uint8Array;
readonly signature: Uint8Array;
readonly userHandle?: Uint8Array | null;
}
export interface RegisterCredentialStub {
readonly id: string;
readonly rawId: Uint8Array;
readonly response: AttestationResponseStub;
readonly type?: string;
readonly extensions?: Record<string, unknown>;
}
export interface AuthenticateCredentialStub {
readonly id?: string;
readonly rawId: Uint8Array;
readonly response: AssertionResponseStub;
readonly type?: string;
readonly extensions?: Record<string, unknown>;
}
export interface AdapterCalls {
createCalls: Array<unknown>;
getCalls: Array<unknown>;
isConditionalMediationAvailableCalls: number;
}
export interface StubAdapterOptions {
readonly onCreate?:
| (() => Promise<unknown> | unknown)
| RegisterCredentialStub;
readonly onGet?:
| (() => Promise<unknown> | unknown)
| AuthenticateCredentialStub;
readonly conditionalMediation?: boolean;
readonly isSupported?: boolean;
}
export function stubAdapter(options: StubAdapterOptions = {}): {
readonly adapter: WebAuthnAdapter;
readonly calls: AdapterCalls;
} {
const calls: AdapterCalls = {
createCalls: [],
getCalls: [],
isConditionalMediationAvailableCalls: 0,
};
const adapter: WebAuthnAdapter = {
async create(opts) {
calls.createCalls.push(opts);
const handler = options.onCreate;
if (handler === undefined) {
return null;
}
if (typeof handler === "function") {
return (await handler()) as never;
}
return wrapRegisterCredential(handler) as unknown as never;
},
async get(opts) {
calls.getCalls.push(opts);
const handler = options.onGet;
if (handler === undefined) {
return null;
}
if (typeof handler === "function") {
return (await handler()) as never;
}
return wrapAuthenticateCredential(handler) as unknown as never;
},
isSupported: () => options.isSupported ?? true,
async isConditionalMediationAvailable() {
calls.isConditionalMediationAvailableCalls += 1;
return options.conditionalMediation ?? false;
},
};
return { adapter, calls };
}
interface CredentialLike {
id: string;
rawId: Uint8Array;
type: string;
response: AttestationResponseStub | AssertionResponseStub;
getClientExtensionResults: () => Record<string, unknown>;
}
interface RegisterCredentialLike extends CredentialLike {
response: AttestationResponseStub & {
getTransports?: () => string[];
};
}
interface AuthenticateCredentialLike extends CredentialLike {
response: AssertionResponseStub;
}
function wrapRegisterCredential(
stub: RegisterCredentialStub
): RegisterCredentialLike {
const transports = [...(stub.response.transports ?? [])];
return {
id: stub.id,
rawId: stub.rawId,
type: stub.type ?? "public-key",
response: {
attestationObject: stub.response.attestationObject,
clientDataJSON: stub.response.clientDataJSON,
getTransports: () => transports,
},
getClientExtensionResults: () => stub.extensions ?? {},
};
}
function wrapAuthenticateCredential(
stub: AuthenticateCredentialStub
): AuthenticateCredentialLike {
return {
id: stub.id ?? "stub",
rawId: stub.rawId,
type: stub.type ?? "public-key",
response: stub.response,
getClientExtensionResults: () => stub.extensions ?? {},
};
}
/** Reject with a DOMException-shaped error. Used to drive the DOM mapper. */
export function rejectWithDomException(
name: string,
message = name
): () => Promise<never> {
return () => {
const err =
typeof DOMException !== "undefined"
? new DOMException(message, name)
: Object.assign(new Error(message), { name });
return Promise.reject(err);
};
}

View File

@@ -0,0 +1,221 @@
/**
* TPM 2.0 attestation-blob builders for webauthn `tpm`-format tests.
*
* Builds real `TPMS_ATTEST` + `TPMT_PUBLIC` structures via
* `@suaveplan/tpm-struct`'s public serializers, plus a real RSA
* private key (via `node:crypto.generateKeyPairSync`) used to sign the
* `certInfo` bytes. The same RSA modulus is embedded both in
* `pubArea.unique.modulus` (so the TPM `name` digest matches what the
* verifier computes from `pubArea`) and in the AIK leaf certificate's
* `subjectPublicKey` (so the signature verifies against the
* caller-provided chain).
*
* The credential public key in `authData` MUST equal `pubArea.unique`,
* which means the COSE_Key in authData has `kty: "RSA"` with the same
* `n` modulus bytes. The fixture exports a helper to produce an
* RSA-shaped COSE_Key matching the same TPM RSA key so callers can
* stitch authData + pubArea consistently.
*/
import { generateKeyPairSync } from "node:crypto";
import type { RsaKey } from "@suaveplan/cbor/cose";
import { sha256 } from "@suaveplan/crypto-hash";
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa";
import {
type ClockInfo,
serializeTpmsAttest,
serializeTpmtPublic,
type TpmsAttestCertify,
type TpmtPublicRsa,
} from "@suaveplan/tpm-struct";
interface RsaTestKey {
readonly n: bigint;
readonly e: bigint;
readonly d: bigint;
readonly p: bigint;
readonly q: bigint;
readonly dp: bigint;
readonly dq: bigint;
readonly qInv: bigint;
readonly bitLength: number;
readonly modulusBytes: Uint8Array;
readonly exponentBytes: Uint8Array;
}
function b64uToBigint(s: string): bigint {
// base64url → bigint big-endian.
const std = s.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (std.length % 4)) % 4;
const padded = std + "=".repeat(padLen);
const bin = atob(padded);
let v = 0n;
for (let i = 0; i < bin.length; i++) {
v = (v << 8n) | BigInt(bin.charCodeAt(i));
}
return v;
}
function bigintToBE(n: bigint, byteLen?: number): Uint8Array {
const bytes: number[] = [];
let v = n;
while (v > 0n) {
bytes.unshift(Number(v & 0xffn));
v >>= 8n;
}
if (byteLen !== undefined) {
while (bytes.length < byteLen) bytes.unshift(0);
}
return new Uint8Array(bytes);
}
let rsaCache: RsaTestKey | undefined;
/** Generate (once) a 2048-bit RSA test keypair for TPM AIK simulation. */
export function getTpmRsaTestKey(): RsaTestKey {
if (rsaCache !== undefined) return rsaCache;
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicExponent: 65537,
});
const jwk = privateKey.export({ format: "jwk" }) as {
n: string;
e: string;
d: string;
p: string;
q: string;
dp: string;
dq: string;
qi: string;
};
const n = b64uToBigint(jwk.n);
const e = b64uToBigint(jwk.e);
const d = b64uToBigint(jwk.d);
const p = b64uToBigint(jwk.p);
const q = b64uToBigint(jwk.q);
const dp = b64uToBigint(jwk.dp);
const dq = b64uToBigint(jwk.dq);
const qInv = b64uToBigint(jwk.qi);
const modulusBytes = bigintToBE(n, 256);
const exponentBytes = bigintToBE(e);
rsaCache = {
n,
e,
d,
p,
q,
dp,
dq,
qInv,
bitLength: n.toString(2).length,
modulusBytes,
exponentBytes,
};
return rsaCache;
}
/** Build a COSE_Key (RSA) matching the test TPM key for embedding in authData. */
export function tpmRsaCoseKey(): RsaKey {
const k = getTpmRsaTestKey();
return {
kty: "RSA",
alg: "RS256",
n: k.modulusBytes,
e: k.exponentBytes,
};
}
/** Build a TPMT_PUBLIC RSA-2048 pubArea whose modulus equals the test key. */
export function buildTpmtPublicRsa(): { pubArea: Uint8Array } {
const k = getTpmRsaTestKey();
const pub: TpmtPublicRsa = {
type: "TPM_ALG_RSA",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: {
raw: 0x00040472,
flags: {
fixedTPM: true,
stClear: false,
fixedParent: true,
sensitiveDataOrigin: true,
userWithAuth: true,
adminWithPolicy: false,
noDA: false,
encryptedDuplication: false,
restricted: false,
decrypt: false,
sign: true,
},
},
authPolicy: new Uint8Array(0),
parameters: {
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_NULL" },
keyBits: 2048,
exponent: 65537,
rawExponent: 0,
},
unique: { modulus: k.modulusBytes },
};
return { pubArea: serializeTpmtPublic(pub) };
}
const DEFAULT_CLOCK_INFO: ClockInfo = {
clock: 123456789n,
resetCount: 7,
restartCount: 3,
safe: true,
};
/** Build a TPMS_ATTEST (CERTIFY) blob whose `extraData` and `attested.name` bind to the supplied inputs. */
export interface BuildTpmsAttestInput {
readonly extraData: Uint8Array;
readonly attestedName: Uint8Array;
readonly clockInfo?: ClockInfo;
readonly qualifiedName?: Uint8Array;
}
export function buildTpmsAttestCertify(input: BuildTpmsAttestInput): {
certInfo: Uint8Array;
} {
const att: TpmsAttestCertify = {
type: "TPM_ST_ATTEST_CERTIFY",
magic: 0xff544347,
qualifiedSigner: new Uint8Array(32).fill(0x11),
extraData: input.extraData,
clockInfo: input.clockInfo ?? DEFAULT_CLOCK_INFO,
firmwareVersion: 0x000700020001ffffn,
attested: {
name: input.attestedName,
qualifiedName:
input.qualifiedName ??
concat(
new Uint8Array([0x00, 0x0b]),
sha256(input.attestedName)
),
},
};
return { certInfo: serializeTpmsAttest(att) };
}
function concat(...parts: readonly Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
/** Sign arbitrary bytes with the test TPM RSA AIK private key (RSA-PKCS1v15 + SHA-256). */
export function signWithTpmAik(message: Uint8Array): Uint8Array {
const k = getTpmRsaTestKey();
return rsaSignPkcs1v15(k, message, { hash: "SHA-256" });
}
/** Compute the canonical TPM `name` of the test pubArea — `0x000b || sha256(pubArea)`. */
export function tpmPubAreaName(pubArea: Uint8Array): Uint8Array {
return concat(new Uint8Array([0x00, 0x0b]), sha256(pubArea));
}

View File

@@ -0,0 +1,443 @@
/**
* Webauthn-tier X.509 chain fixture builders for attestation tests.
*
* Re-exports the deterministic root-only chain helpers from
* `@suaveplan/x509/fixtures` for callers that just need a precomputed
* chain, and adds custom builders that emit a leaf certificate whose
* public key is caller-supplied and whose extensions can be specified
* inline. The custom path is required by the attestation-format tests
* (android-key, apple, tpm) which must cross-bind a credential-specific
* public key into the attestation leaf.
*
* The custom builders use only `@suaveplan/x509/asn1` (universal,
* already public). Private keys for the internal CA + signing helpers
* live here too — never leaked outside the fixture surface.
*/
import { p256 as nobleP256 } from "@noble/curves/p256";
import { sha256 } from "@suaveplan/crypto-hash";
import {
encodeAsn1,
encodeOid,
encodeTime,
makeConstructed,
makePrimitive,
UNIVERSAL_TAGS,
} from "@suaveplan/x509/asn1";
const OID_CN = "2.5.4.3";
const OID_O = "2.5.4.10";
const OID_ECDSA_SHA256 = "1.2.840.10045.4.3.2";
const OID_EC_PUBLIC_KEY = "1.2.840.10045.2.1";
const OID_P256_CURVE = "1.2.840.10045.3.1.7";
const OID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1";
const OID_BASIC_CONSTRAINTS = "2.5.29.19";
const OID_KEY_USAGE = "2.5.29.15";
const OID_SUBJECT_KEY_ID = "2.5.29.14";
const TEXT = new TextEncoder();
const NB = Date.UTC(2024, 0, 1);
const NA = Date.UTC(2030, 0, 1);
/** Deterministic ECDSA P-256 secret key for the webauthn internal CA. */
const CA_SK = new Uint8Array(32).fill(0xa7);
function intNode(n: number): ReturnType<typeof makePrimitive> {
if (n === 0) {
return makePrimitive(
"universal",
UNIVERSAL_TAGS.INTEGER,
new Uint8Array([0])
);
}
const bytes: number[] = [];
let v = n;
while (v > 0) {
bytes.unshift(v & 0xff);
v >>>= 8;
}
if ((bytes[0] as number) & 0x80) bytes.unshift(0x00);
return makePrimitive(
"universal",
UNIVERSAL_TAGS.INTEGER,
new Uint8Array(bytes)
);
}
function bigIntBytesNode(b: Uint8Array): ReturnType<typeof makePrimitive> {
if (b.length === 0) {
return makePrimitive(
"universal",
UNIVERSAL_TAGS.INTEGER,
new Uint8Array([0])
);
}
if ((b[0] as number) & 0x80) {
const out = new Uint8Array(b.length + 1);
out.set(b, 1);
return makePrimitive("universal", UNIVERSAL_TAGS.INTEGER, out);
}
return makePrimitive("universal", UNIVERSAL_TAGS.INTEGER, b);
}
function bigintToBytes(n: bigint): Uint8Array {
if (n === 0n) return new Uint8Array([0]);
const bytes: number[] = [];
let v = n;
while (v > 0n) {
bytes.unshift(Number(v & 0xffn));
v >>= 8n;
}
return new Uint8Array(bytes);
}
function oidNode(dotted: string): ReturnType<typeof makePrimitive> {
return makePrimitive(
"universal",
UNIVERSAL_TAGS.OBJECT_IDENTIFIER,
encodeOid(dotted)
);
}
function nullNode(): ReturnType<typeof makePrimitive> {
return makePrimitive("universal", UNIVERSAL_TAGS.NULL, new Uint8Array());
}
function bitStringNode(bytes: Uint8Array): ReturnType<typeof makePrimitive> {
const out = new Uint8Array(bytes.length + 1);
out[0] = 0x00;
out.set(bytes, 1);
return makePrimitive("universal", UNIVERSAL_TAGS.BIT_STRING, out);
}
function octetStringNode(bytes: Uint8Array): ReturnType<typeof makePrimitive> {
return makePrimitive(
"universal",
UNIVERSAL_TAGS.OCTET_STRING,
new Uint8Array(bytes)
);
}
function boolNode(v: boolean): ReturnType<typeof makePrimitive> {
return makePrimitive(
"universal",
UNIVERSAL_TAGS.BOOLEAN,
new Uint8Array([v ? 0xff : 0x00])
);
}
function printableStringNode(s: string): ReturnType<typeof makePrimitive> {
return makePrimitive(
"universal",
UNIVERSAL_TAGS.PRINTABLE_STRING,
TEXT.encode(s)
);
}
function timeNode(ms: number): ReturnType<typeof makePrimitive> {
const t = encodeTime(ms);
return makePrimitive("universal", t.tagNumber, t.body);
}
function algIdEcdsaSha256(): ReturnType<typeof makeConstructed> {
return makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_ECDSA_SHA256),
]);
}
function name(cn: string): ReturnType<typeof makeConstructed> {
return makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("universal", UNIVERSAL_TAGS.SET, [
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_O),
printableStringNode("Suaveplan Webauthn Test"),
]),
]),
makeConstructed("universal", UNIVERSAL_TAGS.SET, [
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_CN),
printableStringNode(cn),
]),
]),
]);
}
function spkiP256Uncompressed(
pubUncompressed: Uint8Array
): ReturnType<typeof makeConstructed> {
return makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_EC_PUBLIC_KEY),
oidNode(OID_P256_CURVE),
]),
bitStringNode(pubUncompressed),
]);
}
function spkiRsa(
nBytes: Uint8Array,
eBytes: Uint8Array
): ReturnType<typeof makeConstructed> {
const rsaKey = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
bigIntBytesNode(nBytes),
bigIntBytesNode(eBytes),
]);
return makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_RSA_ENCRYPTION),
nullNode(),
]),
bitStringNode(encodeAsn1(rsaKey)),
]);
}
/** A single custom extension to splice into a leaf cert's `extensions` block. */
export interface CustomExtension {
readonly oid: string;
readonly critical?: boolean;
/** Inner OCTET STRING content of the X.509 `extnValue`. */
readonly value: Uint8Array;
}
function buildExtensionsNode(
leafSpkiBytes: Uint8Array,
customExts: readonly CustomExtension[]
): ReturnType<typeof makeConstructed> {
const ski = sha256(leafSpkiBytes);
const items: ReturnType<typeof makeConstructed>[] = [
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_BASIC_CONSTRAINTS),
octetStringNode(
encodeAsn1(
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [])
)
),
]),
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_KEY_USAGE),
boolNode(true),
octetStringNode(
encodeAsn1(
makePrimitive(
"universal",
UNIVERSAL_TAGS.BIT_STRING,
new Uint8Array([0x07, 0x80]) // bit 0 = digitalSignature
)
)
),
]),
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_SUBJECT_KEY_ID),
octetStringNode(encodeAsn1(octetStringNode(ski))),
]),
];
for (const ext of customExts) {
const parts: ReturnType<typeof makeConstructed>[] = [oidNode(ext.oid)];
if (ext.critical === true) parts.push(boolNode(true));
parts.push(octetStringNode(ext.value));
items.push(
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, parts)
);
}
return makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, items);
}
function signWithCa(tbsBytes: Uint8Array): Uint8Array {
const digest = sha256(tbsBytes);
return nobleP256.sign(digest, CA_SK).toBytes("der");
}
/** Result of `buildLeafCertWithExtensions`. */
export interface BuiltLeafCert {
readonly leafDer: Uint8Array;
/** Root CA DER (self-signed P-256 issuer used for the leaf signature). */
readonly rootDer: Uint8Array;
}
let cachedRootDer: Uint8Array | undefined;
function buildRootDer(): Uint8Array {
if (cachedRootDer !== undefined) return cachedRootDer;
const pub = nobleP256.getPublicKey(CA_SK, false);
const spki = spkiP256Uncompressed(pub);
const spkiBytes = encodeAsn1(spki);
const tbs = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("context", 0, [intNode(2)]),
intNode(1),
algIdEcdsaSha256(),
name("Webauthn Test Root"),
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
timeNode(NB),
timeNode(NA),
]),
name("Webauthn Test Root"),
spki,
makeConstructed("context", 3, [buildExtensionsNode(spkiBytes, [])]),
]);
const tbsBytes = encodeAsn1(tbs);
const sig = signWithCa(tbsBytes);
const cert = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
tbs,
algIdEcdsaSha256(),
bitStringNode(sig),
]);
cachedRootDer = encodeAsn1(cert);
return cachedRootDer;
}
/** Build a leaf certificate carrying a caller-supplied P-256 public key. */
export interface BuildEcLeafInput {
/** Subject CN — defaults to `"Webauthn Test Leaf"`. */
readonly subjectCn?: string;
/** Uncompressed P-256 SEC1 point (65 bytes, `0x04 || x || y`). */
readonly publicKeyUncompressed: Uint8Array;
/** Extra X.509 extensions to splice into the leaf. */
readonly extensions?: readonly CustomExtension[];
/** Optional serial — defaults to a unique per-call value. */
readonly serial?: number;
}
let serialCounter = 100;
export function buildLeafCertWithEcPublicKey(
input: BuildEcLeafInput
): BuiltLeafCert {
const subjectCn = input.subjectCn ?? "Webauthn Test Leaf";
const serial = input.serial ?? serialCounter++;
const spki = spkiP256Uncompressed(input.publicKeyUncompressed);
const spkiBytes = encodeAsn1(spki);
const tbs = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("context", 0, [intNode(2)]),
intNode(serial),
algIdEcdsaSha256(),
name("Webauthn Test Root"),
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
timeNode(NB),
timeNode(NA),
]),
name(subjectCn),
spki,
makeConstructed("context", 3, [
buildExtensionsNode(spkiBytes, input.extensions ?? []),
]),
]);
const tbsBytes = encodeAsn1(tbs);
const sig = signWithCa(tbsBytes);
const cert = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
tbs,
algIdEcdsaSha256(),
bitStringNode(sig),
]);
return { leafDer: encodeAsn1(cert), rootDer: buildRootDer() };
}
/** Build a leaf certificate carrying a caller-supplied RSA public key. */
export interface BuildRsaLeafInput {
readonly subjectCn?: string;
/** Modulus bytes (big-endian, big-int form). */
readonly modulus: Uint8Array;
/** Public exponent bytes (big-endian). */
readonly exponent: Uint8Array;
readonly extensions?: readonly CustomExtension[];
readonly serial?: number;
}
export function buildLeafCertWithRsaPublicKey(
input: BuildRsaLeafInput
): BuiltLeafCert {
const subjectCn = input.subjectCn ?? "Webauthn TPM AIK";
const serial = input.serial ?? serialCounter++;
const spki = spkiRsa(input.modulus, input.exponent);
const spkiBytes = encodeAsn1(spki);
const tbs = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("context", 0, [intNode(2)]),
intNode(serial),
algIdEcdsaSha256(),
name("Webauthn Test Root"),
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
timeNode(NB),
timeNode(NA),
]),
name(subjectCn),
spki,
makeConstructed("context", 3, [
buildExtensionsNode(spkiBytes, input.extensions ?? []),
]),
]);
const tbsBytes = encodeAsn1(tbs);
const sig = signWithCa(tbsBytes);
const cert = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
tbs,
algIdEcdsaSha256(),
bitStringNode(sig),
]);
return { leafDer: encodeAsn1(cert), rootDer: buildRootDer() };
}
/** Convert a `BuiltLeafCert` to the `x5c: string[]` form (base64-DER) for JWS headers. */
export function toX5cStringArray(
chain: readonly Uint8Array[]
): readonly string[] {
return chain.map((der) => {
let bin = "";
for (const b of der) bin += String.fromCharCode(b);
return btoa(bin);
});
}
/** Helper to expose `nobleP256` deterministic-k signing. */
export function signWithCaP256(digest: Uint8Array): Uint8Array {
return nobleP256.sign(digest, CA_SK).toBytes("der");
}
/**
* Build a leaf cert whose `subjectPublicKey` is the caller-supplied byte
* string verbatim — wrapped in an RSA-shaped `algorithm` block so the
* downstream verifier reads it as an RSA SPKI. Used to drive the TPM
* verifier's `parseRsaSpki` malformation branches (truncated, missing
* INTEGER tags, oversized DER length).
*/
export interface BuildRawSpkiInput {
readonly subjectCn?: string;
/** Raw bytes placed in the `subjectPublicKey` BIT STRING. */
readonly rawSubjectPublicKey: Uint8Array;
readonly serial?: number;
}
export function buildLeafCertWithRawRsaSpki(
input: BuildRawSpkiInput
): BuiltLeafCert {
const subjectCn = input.subjectCn ?? "Webauthn Malformed RSA AIK";
const serial = input.serial ?? serialCounter++;
const spki = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
oidNode(OID_RSA_ENCRYPTION),
nullNode(),
]),
bitStringNode(input.rawSubjectPublicKey),
]);
const spkiBytes = encodeAsn1(spki);
const tbs = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
makeConstructed("context", 0, [intNode(2)]),
intNode(serial),
algIdEcdsaSha256(),
name("Webauthn Test Root"),
makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
timeNode(NB),
timeNode(NA),
]),
name(subjectCn),
spki,
makeConstructed("context", 3, [buildExtensionsNode(spkiBytes, [])]),
]);
const tbsBytes = encodeAsn1(tbs);
const sig = signWithCa(tbsBytes);
const cert = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
tbs,
algIdEcdsaSha256(),
bitStringNode(sig),
]);
return { leafDer: encodeAsn1(cert), rootDer: buildRootDer() };
}
export { bigintToBytes };