Initial Commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
107
packages/identity/auth/webauthn/src/__fixtures__/cose-encode.ts
Normal file
107
packages/identity/auth/webauthn/src/__fixtures__/cose-encode.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
83
packages/identity/auth/webauthn/src/__fixtures__/index.ts
Normal file
83
packages/identity/auth/webauthn/src/__fixtures__/index.ts
Normal 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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
155
packages/identity/auth/webauthn/src/__fixtures__/stub-adapter.ts
Normal file
155
packages/identity/auth/webauthn/src/__fixtures__/stub-adapter.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
221
packages/identity/auth/webauthn/src/__fixtures__/tpm-attest.ts
Normal file
221
packages/identity/auth/webauthn/src/__fixtures__/tpm-attest.ts
Normal 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));
|
||||
}
|
||||
443
packages/identity/auth/webauthn/src/__fixtures__/x509-chains.ts
Normal file
443
packages/identity/auth/webauthn/src/__fixtures__/x509-chains.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user