@bradford-tech/supabase-integrity-attest 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/esm/_dnt.test_shims.d.ts.map +1 -0
  4. package/esm/deps/jsr.io/@std/assert/1.0.19/almost_equals.d.ts.map +1 -0
  5. package/esm/deps/jsr.io/@std/assert/1.0.19/array_includes.d.ts.map +1 -0
  6. package/esm/deps/jsr.io/@std/assert/1.0.19/assert.d.ts.map +1 -0
  7. package/esm/deps/jsr.io/@std/assert/1.0.19/assertion_error.d.ts.map +1 -0
  8. package/esm/deps/jsr.io/@std/assert/1.0.19/equal.d.ts.map +1 -0
  9. package/esm/deps/jsr.io/@std/assert/1.0.19/equals.d.ts.map +1 -0
  10. package/esm/deps/jsr.io/@std/assert/1.0.19/exists.d.ts.map +1 -0
  11. package/esm/deps/jsr.io/@std/assert/1.0.19/fail.d.ts.map +1 -0
  12. package/esm/deps/jsr.io/@std/assert/1.0.19/false.d.ts.map +1 -0
  13. package/esm/deps/jsr.io/@std/assert/1.0.19/greater.d.ts.map +1 -0
  14. package/esm/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.d.ts.map +1 -0
  15. package/esm/deps/jsr.io/@std/assert/1.0.19/instance_of.d.ts.map +1 -0
  16. package/esm/deps/jsr.io/@std/assert/1.0.19/is_error.d.ts.map +1 -0
  17. package/esm/deps/jsr.io/@std/assert/1.0.19/less.d.ts.map +1 -0
  18. package/esm/deps/jsr.io/@std/assert/1.0.19/less_or_equal.d.ts.map +1 -0
  19. package/esm/deps/jsr.io/@std/assert/1.0.19/match.d.ts.map +1 -0
  20. package/esm/deps/jsr.io/@std/assert/1.0.19/mod.d.ts.map +1 -0
  21. package/esm/deps/jsr.io/@std/assert/1.0.19/not_equals.d.ts.map +1 -0
  22. package/esm/deps/jsr.io/@std/assert/1.0.19/not_instance_of.d.ts.map +1 -0
  23. package/esm/deps/jsr.io/@std/assert/1.0.19/not_match.d.ts.map +1 -0
  24. package/esm/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.d.ts.map +1 -0
  25. package/esm/deps/jsr.io/@std/assert/1.0.19/object_match.d.ts.map +1 -0
  26. package/esm/deps/jsr.io/@std/assert/1.0.19/rejects.d.ts.map +1 -0
  27. package/esm/deps/jsr.io/@std/assert/1.0.19/strict_equals.d.ts.map +1 -0
  28. package/esm/deps/jsr.io/@std/assert/1.0.19/string_includes.d.ts.map +1 -0
  29. package/esm/deps/jsr.io/@std/assert/1.0.19/throws.d.ts.map +1 -0
  30. package/esm/deps/jsr.io/@std/assert/1.0.19/unimplemented.d.ts.map +1 -0
  31. package/esm/deps/jsr.io/@std/assert/1.0.19/unreachable.d.ts.map +1 -0
  32. package/esm/deps/jsr.io/@std/encoding/1.0.10/_common64.d.ts +35 -0
  33. package/esm/deps/jsr.io/@std/encoding/1.0.10/_common64.d.ts.map +1 -0
  34. package/esm/deps/jsr.io/@std/encoding/1.0.10/_common64.js +113 -0
  35. package/esm/deps/jsr.io/@std/encoding/1.0.10/_common_detach.d.ts +4 -0
  36. package/esm/deps/jsr.io/@std/encoding/1.0.10/_common_detach.d.ts.map +1 -0
  37. package/esm/deps/jsr.io/@std/encoding/1.0.10/_common_detach.js +13 -0
  38. package/esm/deps/jsr.io/@std/encoding/1.0.10/_types.d.ts +9 -0
  39. package/esm/deps/jsr.io/@std/encoding/1.0.10/_types.d.ts.map +1 -0
  40. package/esm/deps/jsr.io/@std/encoding/1.0.10/_types.js +2 -0
  41. package/esm/deps/jsr.io/@std/encoding/1.0.10/base64.d.ts +40 -0
  42. package/esm/deps/jsr.io/@std/encoding/1.0.10/base64.d.ts.map +1 -0
  43. package/esm/deps/jsr.io/@std/encoding/1.0.10/base64.js +82 -0
  44. package/esm/deps/jsr.io/@std/internal/1.0.12/build_message.d.ts.map +1 -0
  45. package/esm/deps/jsr.io/@std/internal/1.0.12/diff.d.ts.map +1 -0
  46. package/esm/deps/jsr.io/@std/internal/1.0.12/diff_str.d.ts.map +1 -0
  47. package/esm/deps/jsr.io/@std/internal/1.0.12/format.d.ts.map +1 -0
  48. package/esm/deps/jsr.io/@std/internal/1.0.12/styles.d.ts.map +1 -0
  49. package/esm/deps/jsr.io/@std/internal/1.0.12/types.d.ts.map +1 -0
  50. package/esm/mod.d.ts +6 -0
  51. package/esm/mod.d.ts.map +1 -0
  52. package/esm/mod.js +4 -0
  53. package/esm/package.json +3 -0
  54. package/esm/src/assertion.d.ts +9 -0
  55. package/esm/src/assertion.d.ts.map +1 -0
  56. package/esm/src/assertion.js +64 -0
  57. package/esm/src/attestation.d.ts +37 -0
  58. package/esm/src/attestation.d.ts.map +1 -0
  59. package/esm/src/attestation.js +242 -0
  60. package/esm/src/authdata.d.ts +13 -0
  61. package/esm/src/authdata.d.ts.map +1 -0
  62. package/esm/src/authdata.js +29 -0
  63. package/esm/src/certificate.d.ts +25 -0
  64. package/esm/src/certificate.d.ts.map +1 -0
  65. package/esm/src/certificate.js +86 -0
  66. package/esm/src/constants.d.ts +9 -0
  67. package/esm/src/constants.d.ts.map +1 -0
  68. package/esm/src/constants.js +56 -0
  69. package/esm/src/cose.d.ts.map +1 -0
  70. package/esm/src/der.d.ts +3 -0
  71. package/esm/src/der.d.ts.map +1 -0
  72. package/esm/src/der.js +72 -0
  73. package/esm/src/errors.d.ts +26 -0
  74. package/esm/src/errors.d.ts.map +1 -0
  75. package/esm/src/errors.js +52 -0
  76. package/esm/src/utils.d.ts +13 -0
  77. package/esm/src/utils.d.ts.map +1 -0
  78. package/esm/src/utils.js +52 -0
  79. package/esm/tests/assertion.test.d.ts.map +1 -0
  80. package/esm/tests/attestation.test.d.ts.map +1 -0
  81. package/esm/tests/authdata.test.d.ts.map +1 -0
  82. package/esm/tests/certificate.test.d.ts.map +1 -0
  83. package/esm/tests/cose.test.d.ts.map +1 -0
  84. package/esm/tests/der.test.d.ts.map +1 -0
  85. package/esm/tests/errors.test.d.ts.map +1 -0
  86. package/esm/tests/fixtures/apple-attestation.d.ts.map +1 -0
  87. package/esm/tests/fixtures/generate-assertion.d.ts.map +1 -0
  88. package/esm/tests/utils.test.d.ts.map +1 -0
  89. package/package.json +33 -0
@@ -0,0 +1,64 @@
1
+ // src/assertion.ts
2
+ import { decode } from "cborg";
3
+ import { parseAssertionAuthData } from "./authdata.js";
4
+ import { derToRaw } from "./der.js";
5
+ import { AssertionError, AssertionErrorCode } from "./errors.js";
6
+ import { concat, constantTimeEqual, decodeBase64Bytes, importPemPublicKey, toBytes, } from "./utils.js";
7
+ export async function verifyAssertion(appInfo, assertion, clientData, publicKeyPem, previousSignCount) {
8
+ const assertionBytes = decodeBase64Bytes(assertion);
9
+ const clientDataBytes = toBytes(clientData);
10
+ // Step 1: CBOR decode
11
+ let decoded;
12
+ try {
13
+ decoded = decode(assertionBytes);
14
+ if (!decoded.signature || !decoded.authenticatorData) {
15
+ throw new Error("Missing required fields");
16
+ }
17
+ }
18
+ catch (e) {
19
+ throw new AssertionError(AssertionErrorCode.INVALID_FORMAT, `Failed to decode assertion: ${e instanceof Error ? e.message : String(e)}`);
20
+ }
21
+ // Step 2: Parse authenticatorData
22
+ let authData;
23
+ try {
24
+ authData = parseAssertionAuthData(decoded.authenticatorData);
25
+ }
26
+ catch (e) {
27
+ throw new AssertionError(AssertionErrorCode.INVALID_FORMAT, `Invalid authenticatorData: ${e instanceof Error ? e.message : String(e)}`);
28
+ }
29
+ // Step 3: Verify rpIdHash
30
+ const expectedRpIdHash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appInfo.appId)));
31
+ if (!constantTimeEqual(authData.rpIdHash, expectedRpIdHash)) {
32
+ throw new AssertionError(AssertionErrorCode.RP_ID_MISMATCH, "rpIdHash does not match expected appId");
33
+ }
34
+ // Step 4: Verify counter
35
+ if (authData.signCount <= previousSignCount) {
36
+ throw new AssertionError(AssertionErrorCode.COUNTER_NOT_INCREMENTED, `signCount ${authData.signCount} is not greater than previous ${previousSignCount}`);
37
+ }
38
+ // Step 5: Compute clientDataHash
39
+ const clientDataHash = new Uint8Array(await crypto.subtle.digest("SHA-256", clientDataBytes));
40
+ // Step 6: Build message = authenticatorData || clientDataHash
41
+ const message = concat(decoded.authenticatorData, clientDataHash);
42
+ // Step 7: Convert DER signature to raw r||s
43
+ let signatureRaw;
44
+ try {
45
+ signatureRaw = derToRaw(decoded.signature);
46
+ }
47
+ catch (e) {
48
+ throw new AssertionError(AssertionErrorCode.INVALID_FORMAT, `Invalid DER signature: ${e instanceof Error ? e.message : String(e)}`);
49
+ }
50
+ // Step 8: Import PEM public key
51
+ let publicKey;
52
+ try {
53
+ publicKey = await importPemPublicKey(publicKeyPem);
54
+ }
55
+ catch (e) {
56
+ throw new AssertionError(AssertionErrorCode.INVALID_FORMAT, `Invalid public key PEM: ${e instanceof Error ? e.message : String(e)}`);
57
+ }
58
+ // Step 9: Verify ECDSA signature
59
+ const valid = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, publicKey, signatureRaw, message);
60
+ if (!valid) {
61
+ throw new AssertionError(AssertionErrorCode.SIGNATURE_INVALID, "ECDSA signature verification failed");
62
+ }
63
+ return { signCount: authData.signCount };
64
+ }
@@ -0,0 +1,37 @@
1
+ export interface AppInfo {
2
+ appId: string;
3
+ developmentEnv?: boolean;
4
+ }
5
+ export interface AttestationResult {
6
+ publicKeyPem: string;
7
+ receipt: Uint8Array;
8
+ signCount: number;
9
+ }
10
+ export interface VerifyAttestationOptions {
11
+ /** Override date for certificate chain validation (for testing with expired certs) */
12
+ checkDate?: Date;
13
+ }
14
+ /**
15
+ * Minimal CBOR decoder for Apple App Attest attestation objects.
16
+ *
17
+ * Apple's CBOR encoding of the attestation object contains a receipt field whose
18
+ * byte-string length header is sometimes incorrect (overstated by ~21 bytes).
19
+ * Standard CBOR libraries (cborg) fail to decode this. We use a lightweight
20
+ * structure-aware parser that handles the known attestation object layout:
21
+ * { "fmt": text, "attStmt": { "x5c": [bstr, ...], "receipt": bstr }, "authData": bstr }
22
+ *
23
+ * The parser locates map keys by scanning for known text-string CBOR keys and
24
+ * extracts values based on their CBOR type headers, which avoids relying on
25
+ * potentially incorrect length fields for opaque byte-string values.
26
+ */
27
+ export interface AttestationCbor {
28
+ fmt: string;
29
+ attStmt: {
30
+ x5c: Uint8Array[];
31
+ receipt: Uint8Array;
32
+ };
33
+ authData: Uint8Array;
34
+ }
35
+ export declare function decodeAttestationCbor(data: Uint8Array): AttestationCbor;
36
+ export declare function verifyAttestation(appInfo: AppInfo, keyId: string, challenge: Uint8Array | string, attestation: Uint8Array | string, options?: VerifyAttestationOptions): Promise<AttestationResult>;
37
+ //# sourceMappingURL=attestation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attestation.d.ts","sourceRoot":"","sources":["../../src/src/attestation.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,UAAU,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,sFAAsF;IACtF,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE;QACP,GAAG,EAAE,UAAU,EAAE,CAAC;QAClB,OAAO,EAAE,UAAU,CAAC;KACrB,CAAC;IACF,QAAQ,EAAE,UAAU,CAAC;CACtB;AAsGD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,UAAU,GAAG,eAAe,CAyEvE;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,UAAU,GAAG,MAAM,EAC9B,WAAW,EAAE,UAAU,GAAG,MAAM,EAChC,OAAO,CAAC,EAAE,wBAAwB,GACjC,OAAO,CAAC,iBAAiB,CAAC,CAkJ5B"}
@@ -0,0 +1,242 @@
1
+ // src/attestation.ts
2
+ import { decodeBase64 } from "../deps/jsr.io/@std/encoding/1.0.10/base64.js";
3
+ import { extractNonceFromCert, extractPublicKeyFromCert, verifyCertificateChain, } from "./certificate.js";
4
+ import { AAGUID_DEVELOPMENT, AAGUID_PRODUCTION } from "./constants.js";
5
+ import { AttestationError, AttestationErrorCode } from "./errors.js";
6
+ import { parseAttestationAuthData } from "./authdata.js";
7
+ import { concat, constantTimeEqual, exportKeyToPem, toBytes } from "./utils.js";
8
+ /** Read a CBOR unsigned integer (additional info + following bytes). */
9
+ function readCborUint(data, offset) {
10
+ const additional = data[offset] & 0x1f;
11
+ if (additional < 24)
12
+ return { value: additional, end: offset + 1 };
13
+ if (additional === 24)
14
+ return { value: data[offset + 1], end: offset + 2 };
15
+ if (additional === 25) {
16
+ return {
17
+ value: (data[offset + 1] << 8) | data[offset + 2],
18
+ end: offset + 3,
19
+ };
20
+ }
21
+ if (additional === 26) {
22
+ return {
23
+ value: ((data[offset + 1] << 24) >>> 0) +
24
+ (data[offset + 2] << 16) +
25
+ (data[offset + 3] << 8) +
26
+ data[offset + 4],
27
+ end: offset + 5,
28
+ };
29
+ }
30
+ throw new Error(`Unsupported CBOR additional info: ${additional}`);
31
+ }
32
+ /** Read a CBOR text string starting at offset. Returns {value, end}. */
33
+ function readCborText(data, offset) {
34
+ const majorType = (data[offset] >> 5) & 0x07;
35
+ if (majorType !== 3) {
36
+ throw new Error(`Expected CBOR text string (type 3) at offset ${offset}, got type ${majorType}`);
37
+ }
38
+ const { value: len, end: dataStart } = readCborUint(data, offset);
39
+ const text = new TextDecoder().decode(data.slice(dataStart, dataStart + len));
40
+ return { value: text, end: dataStart + len };
41
+ }
42
+ /** Read a CBOR byte string starting at offset. Returns {value, end}. */
43
+ function readCborBytes(data, offset) {
44
+ const majorType = (data[offset] >> 5) & 0x07;
45
+ if (majorType !== 2) {
46
+ throw new Error(`Expected CBOR byte string (type 2) at offset ${offset}, got type ${majorType}`);
47
+ }
48
+ const { value: len, end: dataStart } = readCborUint(data, offset);
49
+ return {
50
+ value: data.slice(dataStart, dataStart + len),
51
+ end: dataStart + len,
52
+ };
53
+ }
54
+ /**
55
+ * Find the position of a CBOR text-string key within a byte sequence.
56
+ * Searches for the CBOR encoding of a text string (type 3 header + UTF-8 bytes).
57
+ */
58
+ function findCborTextKey(data, key, startOffset) {
59
+ const keyBytes = new TextEncoder().encode(key);
60
+ // Build the expected CBOR encoding: type 3 header + key bytes
61
+ let header;
62
+ if (keyBytes.length < 24) {
63
+ header = new Uint8Array([0x60 | keyBytes.length]);
64
+ }
65
+ else if (keyBytes.length < 256) {
66
+ header = new Uint8Array([0x78, keyBytes.length]);
67
+ }
68
+ else {
69
+ header = new Uint8Array([
70
+ 0x79,
71
+ keyBytes.length >> 8,
72
+ keyBytes.length & 0xff,
73
+ ]);
74
+ }
75
+ const needle = new Uint8Array(header.length + keyBytes.length);
76
+ needle.set(header);
77
+ needle.set(keyBytes, header.length);
78
+ for (let i = startOffset; i <= data.length - needle.length; i++) {
79
+ let match = true;
80
+ for (let j = 0; j < needle.length; j++) {
81
+ if (data[i + j] !== needle[j]) {
82
+ match = false;
83
+ break;
84
+ }
85
+ }
86
+ if (match)
87
+ return i;
88
+ }
89
+ return -1;
90
+ }
91
+ export function decodeAttestationCbor(data) {
92
+ // Verify top-level is a CBOR map
93
+ const majorType = (data[0] >> 5) & 0x07;
94
+ if (majorType !== 5) {
95
+ throw new Error("Expected CBOR map at top level");
96
+ }
97
+ // Find "fmt" key and read its value
98
+ const fmtKeyPos = findCborTextKey(data, "fmt", 0);
99
+ if (fmtKeyPos === -1)
100
+ throw new Error('Missing "fmt" key');
101
+ const fmtKeyEnd = fmtKeyPos + 1 + 3; // 0x63 + "fmt"
102
+ const { value: fmt } = readCborText(data, fmtKeyEnd);
103
+ // Find "attStmt" key
104
+ const attStmtKeyPos = findCborTextKey(data, "attStmt", 0);
105
+ if (attStmtKeyPos === -1)
106
+ throw new Error('Missing "attStmt" key');
107
+ // After "attStmt" key: 0x67 + "attStmt" = 8 bytes
108
+ const attStmtValuePos = attStmtKeyPos + 8;
109
+ // attStmt value should be a map
110
+ const attStmtMajor = (data[attStmtValuePos] >> 5) & 0x07;
111
+ if (attStmtMajor !== 5)
112
+ throw new Error("attStmt is not a CBOR map");
113
+ // Find "x5c" key within attStmt
114
+ const x5cKeyPos = findCborTextKey(data, "x5c", attStmtValuePos);
115
+ if (x5cKeyPos === -1)
116
+ throw new Error('Missing "x5c" key in attStmt');
117
+ const x5cValuePos = x5cKeyPos + 4; // 0x63 + "x5c"
118
+ // x5c value is an array
119
+ const x5cMajor = (data[x5cValuePos] >> 5) & 0x07;
120
+ if (x5cMajor !== 4)
121
+ throw new Error("x5c is not a CBOR array");
122
+ const { value: x5cCount, end: x5cFirstItemPos } = readCborUint(data, x5cValuePos);
123
+ // Read each certificate byte string
124
+ const x5c = [];
125
+ let pos = x5cFirstItemPos;
126
+ for (let i = 0; i < x5cCount; i++) {
127
+ const { value: cert, end } = readCborBytes(data, pos);
128
+ x5c.push(cert);
129
+ pos = end;
130
+ }
131
+ // After x5c, find "receipt" key
132
+ const receiptKeyPos = findCborTextKey(data, "receipt", pos);
133
+ if (receiptKeyPos === -1)
134
+ throw new Error('Missing "receipt" key in attStmt');
135
+ // Find "authData" key - search from after the receipt key position
136
+ const authDataKeyPos = findCborTextKey(data, "authData", receiptKeyPos);
137
+ if (authDataKeyPos === -1) {
138
+ throw new Error('Missing "authData" key');
139
+ }
140
+ // The receipt value is the bytes between the receipt key end and the authData key start.
141
+ // Receipt key: 0x67 + "receipt" = 8 bytes
142
+ const receiptValueStart = receiptKeyPos + 8;
143
+ // Read the receipt CBOR header to get the data start offset
144
+ const receiptMajor = (data[receiptValueStart] >> 5) & 0x07;
145
+ if (receiptMajor !== 2)
146
+ throw new Error("receipt is not a CBOR byte string");
147
+ const { end: receiptDataStart } = readCborUint(data, receiptValueStart);
148
+ // The actual receipt data extends to just before the authData key.
149
+ // This handles the case where Apple's CBOR length header is incorrect.
150
+ const receipt = data.slice(receiptDataStart, authDataKeyPos);
151
+ // Read authData value
152
+ // authData key: 0x68 + "authData" = 9 bytes
153
+ const authDataValuePos = authDataKeyPos + 9;
154
+ const { value: authData } = readCborBytes(data, authDataValuePos);
155
+ return { fmt, attStmt: { x5c, receipt }, authData };
156
+ }
157
+ export async function verifyAttestation(appInfo, keyId, challenge, attestation, options) {
158
+ // Decode attestation bytes from base64 if string
159
+ let attestationBytes;
160
+ if (typeof attestation === "string") {
161
+ try {
162
+ attestationBytes = decodeBase64(attestation);
163
+ }
164
+ catch {
165
+ throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, "Failed to decode attestation base64");
166
+ }
167
+ }
168
+ else {
169
+ attestationBytes = attestation;
170
+ }
171
+ // Step 1: CBOR decode attestation -> { fmt, attStmt: { x5c, receipt }, authData }
172
+ let decoded;
173
+ try {
174
+ decoded = decodeAttestationCbor(attestationBytes);
175
+ }
176
+ catch {
177
+ throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, "Failed to CBOR-decode attestation object");
178
+ }
179
+ // Step 2: Validate fmt === "apple-appattest"
180
+ if (decoded.fmt !== "apple-appattest") {
181
+ throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, `Invalid attestation format: expected "apple-appattest", got "${decoded.fmt}"`);
182
+ }
183
+ const { x5c, receipt } = decoded.attStmt;
184
+ const authData = decoded.authData;
185
+ // Step 3: Verify x5c cert chain exists (length >= 2)
186
+ if (x5c.length < 2) {
187
+ throw new AttestationError(AttestationErrorCode.INVALID_CERTIFICATE_CHAIN, "Certificate chain (x5c) must contain at least 2 certificates");
188
+ }
189
+ // Step 4: Verify certificate chain
190
+ await verifyCertificateChain(x5c, options?.checkDate);
191
+ // Step 5-6: Compute nonce = SHA-256(authData || challenge)
192
+ // Apple's nonce verification uses the raw challenge bytes concatenated with authData,
193
+ // NOT a hash of the challenge.
194
+ const challengeBytes = toBytes(challenge);
195
+ const nonceInput = concat(authData, challengeBytes);
196
+ const computedNonce = new Uint8Array(await crypto.subtle.digest("SHA-256", nonceInput));
197
+ // Step 7: Extract nonce from leaf cert
198
+ const certNonce = extractNonceFromCert(x5c[0]);
199
+ // Step 8: constantTimeEqual(computedNonce, certNonce)
200
+ if (!constantTimeEqual(computedNonce, certNonce)) {
201
+ throw new AttestationError(AttestationErrorCode.NONCE_MISMATCH, "Computed nonce does not match certificate nonce");
202
+ }
203
+ // Step 9: Extract public key from leaf cert (65 bytes raw)
204
+ const publicKeyRaw = await extractPublicKeyFromCert(x5c[0]);
205
+ // Step 10: SHA-256(publicKeyRaw) must equal base64-decoded keyId
206
+ const publicKeyHash = new Uint8Array(await crypto.subtle.digest("SHA-256", publicKeyRaw));
207
+ const keyIdBytes = decodeBase64(keyId);
208
+ if (!constantTimeEqual(publicKeyHash, keyIdBytes)) {
209
+ throw new AttestationError(AttestationErrorCode.KEY_ID_MISMATCH, "Public key hash does not match keyId");
210
+ }
211
+ // Step 11: Parse authData
212
+ const parsedAuthData = parseAttestationAuthData(authData);
213
+ // Step 12: Verify rpIdHash === SHA-256(appId)
214
+ const appIdHash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appInfo.appId)));
215
+ if (!constantTimeEqual(parsedAuthData.rpIdHash, appIdHash)) {
216
+ throw new AttestationError(AttestationErrorCode.RP_ID_MISMATCH, "RP ID hash does not match SHA-256 of appId");
217
+ }
218
+ // Step 13: Verify signCount === 0
219
+ if (parsedAuthData.signCount !== 0) {
220
+ throw new AttestationError(AttestationErrorCode.INVALID_COUNTER, `Expected signCount 0 for attestation, got ${parsedAuthData.signCount}`);
221
+ }
222
+ // Step 14: Verify AAGUID matches expected (prod or dev)
223
+ const expectedAaguid = appInfo.developmentEnv
224
+ ? AAGUID_DEVELOPMENT
225
+ : AAGUID_PRODUCTION;
226
+ if (!constantTimeEqual(parsedAuthData.aaguid, expectedAaguid)) {
227
+ throw new AttestationError(AttestationErrorCode.INVALID_AAGUID, `AAGUID mismatch: expected ${appInfo.developmentEnv ? "development" : "production"} environment`);
228
+ }
229
+ // Step 15: Verify credentialId === keyIdBytes
230
+ if (!constantTimeEqual(parsedAuthData.credentialId, keyIdBytes)) {
231
+ throw new AttestationError(AttestationErrorCode.KEY_ID_MISMATCH, "Credential ID does not match keyId");
232
+ }
233
+ // Step 16: Export public key as PEM
234
+ const cryptoKey = await crypto.subtle.importKey("raw", publicKeyRaw, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
235
+ const publicKeyPem = await exportKeyToPem(cryptoKey);
236
+ // Step 17: Return result
237
+ return {
238
+ publicKeyPem,
239
+ receipt,
240
+ signCount: 0,
241
+ };
242
+ }
@@ -0,0 +1,13 @@
1
+ export interface AssertionAuthData {
2
+ rpIdHash: Uint8Array;
3
+ flags: number;
4
+ signCount: number;
5
+ }
6
+ export interface AttestationAuthData extends AssertionAuthData {
7
+ aaguid: Uint8Array;
8
+ credentialId: Uint8Array;
9
+ coseKeyBytes: Uint8Array;
10
+ }
11
+ export declare function parseAssertionAuthData(data: Uint8Array): AssertionAuthData;
12
+ export declare function parseAttestationAuthData(data: Uint8Array): AttestationAuthData;
13
+ //# sourceMappingURL=authdata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authdata.d.ts","sourceRoot":"","sources":["../../src/src/authdata.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,UAAU,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;IAC5D,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,EAAE,UAAU,CAAC;IACzB,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,UAAU,GAAG,iBAAiB,CAgB1E;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,UAAU,GACf,mBAAmB,CAiCrB"}
@@ -0,0 +1,29 @@
1
+ // src/authdata.ts
2
+ export function parseAssertionAuthData(data) {
3
+ if (data.length < 37) {
4
+ throw new Error(`authenticatorData too short: expected at least 37 bytes, got ${data.length}`);
5
+ }
6
+ const rpIdHash = data.slice(0, 32);
7
+ const flags = data[32];
8
+ const signCount = new DataView(data.buffer, data.byteOffset + 33, 4).getUint32(0, false);
9
+ return { rpIdHash, flags, signCount };
10
+ }
11
+ export function parseAttestationAuthData(data) {
12
+ const base = parseAssertionAuthData(data);
13
+ if (data.length < 55) {
14
+ throw new Error(`attestation authenticatorData too short: expected at least 55 bytes, got ${data.length}`);
15
+ }
16
+ const aaguid = data.slice(37, 53);
17
+ const credentialIdLength = new DataView(data.buffer, data.byteOffset + 53, 2).getUint16(0, false);
18
+ if (data.length < 55 + credentialIdLength) {
19
+ throw new Error(`authenticatorData truncated: credentialIdLength=${credentialIdLength} but only ${data.length - 55} bytes remain`);
20
+ }
21
+ const credentialId = data.slice(55, 55 + credentialIdLength);
22
+ const coseKeyBytes = data.slice(55 + credentialIdLength);
23
+ return {
24
+ ...base,
25
+ aaguid,
26
+ credentialId,
27
+ coseKeyBytes,
28
+ };
29
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Verify the x5c certificate chain against the Apple App Attestation Root CA.
3
+ *
4
+ * @param x5c - Array of DER-encoded certificates (index 0 = leaf, last = closest to root)
5
+ * @param checkDate - Date to use for validity checking (defaults to now; override for testing expired certs)
6
+ */
7
+ export declare function verifyCertificateChain(x5c: Uint8Array[], checkDate?: Date): Promise<void>;
8
+ /**
9
+ * Extract the nonce from the Apple App Attest credential certificate.
10
+ *
11
+ * The nonce is stored in an extension with OID 1.2.840.113635.100.8.2.
12
+ * The extension value is ASN.1: SEQUENCE { [1] EXPLICIT { OCTET STRING <nonce> } }
13
+ *
14
+ * @param certDer - DER-encoded leaf certificate
15
+ * @returns The 32-byte nonce
16
+ */
17
+ export declare function extractNonceFromCert(certDer: Uint8Array): Uint8Array;
18
+ /**
19
+ * Extract the raw (uncompressed) public key from a DER-encoded certificate.
20
+ *
21
+ * @param certDer - DER-encoded certificate
22
+ * @returns 65-byte uncompressed EC point (0x04 || x || y)
23
+ */
24
+ export declare function extractPublicKeyFromCert(certDer: Uint8Array): Promise<Uint8Array>;
25
+ //# sourceMappingURL=certificate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"certificate.d.ts","sourceRoot":"","sources":["../../src/src/certificate.ts"],"names":[],"mappings":"AAsBA;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,UAAU,EAAE,EACjB,SAAS,CAAC,EAAE,IAAI,GACf,OAAO,CAAC,IAAI,CAAC,CA2Bf;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,UAAU,GAAG,UAAU,CAqCpE;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,UAAU,CAAC,CAkBrB"}
@@ -0,0 +1,86 @@
1
+ // src/certificate.ts
2
+ import * as pkijs from "pkijs";
3
+ import * as asn1js from "asn1js";
4
+ import { APPLE_APP_ATTESTATION_ROOT_CA_PEM, APPLE_NONCE_EXTENSION_OID, } from "./constants.js";
5
+ import { AttestationError, AttestationErrorCode } from "./errors.js";
6
+ /**
7
+ * Parse the Apple App Attestation Root CA from PEM into a pkijs Certificate.
8
+ */
9
+ function parseRootCa() {
10
+ const b64 = APPLE_APP_ATTESTATION_ROOT_CA_PEM
11
+ .replace("-----BEGIN CERTIFICATE-----", "")
12
+ .replace("-----END CERTIFICATE-----", "")
13
+ .replace(/\s/g, "");
14
+ const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
15
+ return pkijs.Certificate.fromBER(der);
16
+ }
17
+ /**
18
+ * Verify the x5c certificate chain against the Apple App Attestation Root CA.
19
+ *
20
+ * @param x5c - Array of DER-encoded certificates (index 0 = leaf, last = closest to root)
21
+ * @param checkDate - Date to use for validity checking (defaults to now; override for testing expired certs)
22
+ */
23
+ export async function verifyCertificateChain(x5c, checkDate) {
24
+ if (x5c.length === 0) {
25
+ throw new AttestationError(AttestationErrorCode.INVALID_CERTIFICATE_CHAIN, "Certificate chain (x5c) is empty");
26
+ }
27
+ const rootCa = parseRootCa();
28
+ // Parse all x5c certs
29
+ const certs = x5c.map((der) => pkijs.Certificate.fromBER(der));
30
+ const chainEngine = new pkijs.CertificateChainValidationEngine({
31
+ trustedCerts: [rootCa],
32
+ certs: certs,
33
+ checkDate: checkDate ?? new Date(),
34
+ });
35
+ const result = await chainEngine.verify();
36
+ if (!result.result) {
37
+ throw new AttestationError(AttestationErrorCode.INVALID_CERTIFICATE_CHAIN, `Certificate chain verification failed: ${result.resultMessage}`);
38
+ }
39
+ }
40
+ /**
41
+ * Extract the nonce from the Apple App Attest credential certificate.
42
+ *
43
+ * The nonce is stored in an extension with OID 1.2.840.113635.100.8.2.
44
+ * The extension value is ASN.1: SEQUENCE { [1] EXPLICIT { OCTET STRING <nonce> } }
45
+ *
46
+ * @param certDer - DER-encoded leaf certificate
47
+ * @returns The 32-byte nonce
48
+ */
49
+ export function extractNonceFromCert(certDer) {
50
+ const cert = pkijs.Certificate.fromBER(certDer);
51
+ const extensions = cert.extensions;
52
+ if (!extensions) {
53
+ throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, "Certificate has no extensions");
54
+ }
55
+ const nonceExt = extensions.find((ext) => ext.extnID === APPLE_NONCE_EXTENSION_OID);
56
+ if (!nonceExt) {
57
+ throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, `Certificate missing nonce extension (OID ${APPLE_NONCE_EXTENSION_OID})`);
58
+ }
59
+ // Parse the extension value as ASN.1
60
+ const extValue = nonceExt.extnValue.getValue();
61
+ const asn1 = asn1js.fromBER(extValue);
62
+ if (asn1.offset === -1) {
63
+ throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, "Failed to parse nonce extension ASN.1");
64
+ }
65
+ // Navigate: SEQUENCE -> tagged [1] -> OCTET STRING
66
+ const sequence = asn1.result;
67
+ const tagged = sequence.valueBlock.value[0];
68
+ const octetString = tagged.valueBlock.value[0];
69
+ return new Uint8Array(octetString.valueBlock.valueHexView);
70
+ }
71
+ /**
72
+ * Extract the raw (uncompressed) public key from a DER-encoded certificate.
73
+ *
74
+ * @param certDer - DER-encoded certificate
75
+ * @returns 65-byte uncompressed EC point (0x04 || x || y)
76
+ */
77
+ export async function extractPublicKeyFromCert(certDer) {
78
+ const cert = pkijs.Certificate.fromBER(certDer);
79
+ // Get the SubjectPublicKeyInfo and convert to DER
80
+ const spkiDer = cert.subjectPublicKeyInfo.toSchema().toBER(false);
81
+ // Import as SPKI to get a CryptoKey
82
+ const cryptoKey = await crypto.subtle.importKey("spki", spkiDer, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
83
+ // Export as raw (uncompressed point)
84
+ const rawKey = await crypto.subtle.exportKey("raw", cryptoKey);
85
+ return new Uint8Array(rawKey);
86
+ }
@@ -0,0 +1,9 @@
1
+ /** Apple App Attestation Root CA (single cert for both dev and prod environments). */
2
+ export declare const APPLE_APP_ATTESTATION_ROOT_CA_PEM = "-----BEGIN CERTIFICATE-----\nMIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw\nJAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK\nQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa\nFw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv\nbiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y\nbmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh\nNbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au\nYen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/\nMB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw\nCgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn\n53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV\noyFraWVIyd/dganmrduC1bmTBGwD\n-----END CERTIFICATE-----";
3
+ /** Production AAGUID: "appattest" + 7 null bytes (16 bytes total) */
4
+ export declare const AAGUID_PRODUCTION: Uint8Array<ArrayBuffer>;
5
+ /** Development AAGUID: "appattestdevelop" (16 bytes, no nulls) */
6
+ export declare const AAGUID_DEVELOPMENT: Uint8Array<ArrayBuffer>;
7
+ /** OID for the Apple App Attest nonce extension in credential certificates */
8
+ export declare const APPLE_NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2";
9
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/src/constants.ts"],"names":[],"mappings":"AAEA,sFAAsF;AACtF,eAAO,MAAM,iCAAiC,+yBAapB,CAAC;AAE3B,qEAAqE;AACrE,eAAO,MAAM,iBAAiB,yBAiB5B,CAAC;AAEH,kEAAkE;AAClE,eAAO,MAAM,kBAAkB,yBAiB7B,CAAC;AAEH,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,2BAA2B,CAAC"}
@@ -0,0 +1,56 @@
1
+ // src/constants.ts
2
+ /** Apple App Attestation Root CA (single cert for both dev and prod environments). */
3
+ export const APPLE_APP_ATTESTATION_ROOT_CA_PEM = `-----BEGIN CERTIFICATE-----
4
+ MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
5
+ JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
6
+ QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
7
+ Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
8
+ biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
9
+ bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
10
+ NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
11
+ Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
12
+ MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
13
+ CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
14
+ 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
15
+ oyFraWVIyd/dganmrduC1bmTBGwD
16
+ -----END CERTIFICATE-----`;
17
+ /** Production AAGUID: "appattest" + 7 null bytes (16 bytes total) */
18
+ export const AAGUID_PRODUCTION = new Uint8Array([
19
+ 0x61,
20
+ 0x70,
21
+ 0x70,
22
+ 0x61,
23
+ 0x74,
24
+ 0x74,
25
+ 0x65,
26
+ 0x73,
27
+ 0x74,
28
+ 0x00,
29
+ 0x00,
30
+ 0x00,
31
+ 0x00,
32
+ 0x00,
33
+ 0x00,
34
+ 0x00,
35
+ ]);
36
+ /** Development AAGUID: "appattestdevelop" (16 bytes, no nulls) */
37
+ export const AAGUID_DEVELOPMENT = new Uint8Array([
38
+ 0x61,
39
+ 0x70,
40
+ 0x70,
41
+ 0x61,
42
+ 0x74,
43
+ 0x74,
44
+ 0x65,
45
+ 0x73,
46
+ 0x74,
47
+ 0x64,
48
+ 0x65,
49
+ 0x76,
50
+ 0x65,
51
+ 0x6c,
52
+ 0x6f,
53
+ 0x70,
54
+ ]);
55
+ /** OID for the Apple App Attest nonce extension in credential certificates */
56
+ export const APPLE_NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2";
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cose.d.ts","sourceRoot":"","sources":["../../src/src/cose.ts"],"names":[],"mappings":"AAGA,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,UAAU,GAAG,UAAU,CAkBvE;AAED,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,UAAU,GACpB,OAAO,CAAC,SAAS,CAAC,CASpB"}
@@ -0,0 +1,3 @@
1
+ export declare function derToRaw(der: Uint8Array): Uint8Array;
2
+ export declare function rawToDer(raw: Uint8Array): Uint8Array;
3
+ //# sourceMappingURL=der.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"der.d.ts","sourceRoot":"","sources":["../../src/src/der.ts"],"names":[],"mappings":"AAEA,wBAAgB,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CAoCpD;AAED,wBAAgB,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CAiBpD"}
package/esm/src/der.js ADDED
@@ -0,0 +1,72 @@
1
+ // src/der.ts
2
+ export function derToRaw(der) {
3
+ if (der[0] !== 0x30) {
4
+ throw new Error("Invalid DER signature: expected SEQUENCE tag (0x30)");
5
+ }
6
+ let offset = 2;
7
+ if (der[1] & 0x80) {
8
+ const lengthBytes = der[1] & 0x7f;
9
+ offset = 2 + lengthBytes;
10
+ }
11
+ const raw = new Uint8Array(64);
12
+ if (der[offset] !== 0x02) {
13
+ throw new Error("Invalid DER signature: expected INTEGER tag (0x02) for r");
14
+ }
15
+ offset++;
16
+ const rLen = der[offset++];
17
+ const rBytes = der.subarray(offset, offset + rLen);
18
+ offset += rLen;
19
+ if (der[offset] !== 0x02) {
20
+ throw new Error("Invalid DER signature: expected INTEGER tag (0x02) for s");
21
+ }
22
+ offset++;
23
+ const sLen = der[offset++];
24
+ const sBytes = der.subarray(offset, offset + sLen);
25
+ copyInteger(rBytes, raw, 0);
26
+ copyInteger(sBytes, raw, 32);
27
+ return raw;
28
+ }
29
+ export function rawToDer(raw) {
30
+ if (raw.length !== 64) {
31
+ throw new Error(`Invalid raw signature: expected 64 bytes, got ${raw.length}`);
32
+ }
33
+ const r = encodeInteger(raw.subarray(0, 32));
34
+ const s = encodeInteger(raw.subarray(32, 64));
35
+ const seqLen = r.length + s.length;
36
+ const der = new Uint8Array(2 + seqLen);
37
+ der[0] = 0x30;
38
+ der[1] = seqLen;
39
+ der.set(r, 2);
40
+ der.set(s, 2 + r.length);
41
+ return der;
42
+ }
43
+ function copyInteger(src, dst, dstOffset) {
44
+ let srcOffset = 0;
45
+ while (srcOffset < src.length - 1 && src[srcOffset] === 0) {
46
+ srcOffset++;
47
+ }
48
+ const len = src.length - srcOffset;
49
+ if (len > 32) {
50
+ throw new Error(`Integer too large: ${len} bytes`);
51
+ }
52
+ dst.set(src.subarray(srcOffset), dstOffset + (32 - len));
53
+ }
54
+ function encodeInteger(value) {
55
+ let start = 0;
56
+ while (start < value.length - 1 && value[start] === 0) {
57
+ start++;
58
+ }
59
+ const needsPadding = value[start] & 0x80;
60
+ const len = value.length - start + (needsPadding ? 1 : 0);
61
+ const result = new Uint8Array(2 + len);
62
+ result[0] = 0x02;
63
+ result[1] = len;
64
+ if (needsPadding) {
65
+ result[2] = 0x00;
66
+ result.set(value.subarray(start), 3);
67
+ }
68
+ else {
69
+ result.set(value.subarray(start), 2);
70
+ }
71
+ return result;
72
+ }