attestid_js_sdk 0.1.11 → 0.1.12
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.
- package/README.md +22 -0
- package/dist/core/index.cjs +143 -28
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +45 -1
- package/dist/core/index.d.ts +45 -1
- package/dist/core/index.js +142 -28
- package/dist/core/index.js.map +1 -1
- package/dist/react/index.cjs +62 -62
- package/dist/react/index.d.cts +31 -0
- package/dist/react/index.d.ts +31 -0
- package/dist/react/index.js +62 -62
- package/dist/styles/attestid.css +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -107,6 +107,28 @@ States: `idle`, `quality_check`, `uploading`, `retrying`, `liveness`, `completin
|
|
|
107
107
|
|
|
108
108
|
Supported `idType` values: `national_id`, `passport`, `drivers_licence`.
|
|
109
109
|
|
|
110
|
+
### Back of the card (optional)
|
|
111
|
+
|
|
112
|
+
Many ID cards and driver's licences print their machine-readable zone (MRZ, the lines of `<<<`) on the back. For `national_id` and `drivers_licence` you can also send a photo of the back. The API cross-checks the MRZ's date of birth and expiry against the front, and rejects a mismatch as `document_sides_mismatch`. A back with no MRZ adds nothing, so check it first and only send it when an MRZ was found:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { checkBackPhotoQuality } from 'attestid_js_sdk';
|
|
116
|
+
|
|
117
|
+
const back = await checkBackPhotoQuality(backPhoto);
|
|
118
|
+
startVerification({
|
|
119
|
+
userId, idType: 'national_id', idPhoto, consentGivenAt,
|
|
120
|
+
idPhotoBack: back.hasMrz ? backPhoto : undefined,
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Whether to ask for the back is each company's choice, set on the AttestID dashboard under Settings: `off` (the default; a back sent anyway is ignored), `optional` (ask, but the user can skip it) or `required` (the back must be sent with a readable MRZ). Read it with `client.getCaptureSettings(userId)`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const { document_back } = await client.getCaptureSettings(userId); // 'off' | 'optional' | 'required'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`AttestIDVerifier` handles all of this for you. It reads the setting, then after the front it asks for the back when the policy is `optional` (with a skip button) or `required` (no skip).
|
|
131
|
+
|
|
110
132
|
## Verify results on your backend
|
|
111
133
|
|
|
112
134
|
Always verify `result.attestation` on your backend before acting on a result.
|
package/dist/core/index.cjs
CHANGED
|
@@ -40,6 +40,7 @@ __export(core_exports, {
|
|
|
40
40
|
buildHandoffUrl: () => buildHandoffUrl,
|
|
41
41
|
canRetryLiveness: () => canRetryLiveness,
|
|
42
42
|
canonicalJson: () => canonicalJson,
|
|
43
|
+
checkBackPhotoQuality: () => checkBackPhotoQuality,
|
|
43
44
|
checkCaptureConditions: () => checkCaptureConditions,
|
|
44
45
|
checkPhotoQuality: () => checkPhotoQuality,
|
|
45
46
|
classifyConditions: () => classifyConditions,
|
|
@@ -254,6 +255,8 @@ function countTextRows(grey, w, h, x0, aw) {
|
|
|
254
255
|
return textRows;
|
|
255
256
|
}
|
|
256
257
|
var EDGE_DELTA = 28;
|
|
258
|
+
var EDGE_DELTA_MIN = 12;
|
|
259
|
+
var EDGE_DELTA_P99_FRACTION = 0.8;
|
|
257
260
|
var NOISY_MEDIAN_STEP = 9;
|
|
258
261
|
var SKEW_TANS = [0, 0.0175, -0.0175, 0.0349, -0.0349, 0.0524, -0.0524, 0.0699, -0.0699];
|
|
259
262
|
var STRIPS = 8;
|
|
@@ -269,14 +272,15 @@ function idTextNotFoundMessage(idType) {
|
|
|
269
272
|
const doc = idType === "drivers_licence" ? "driver's licence" : "ID card";
|
|
270
273
|
return `We could not detect readable printed details on your ${doc}. Photograph the front of the card (the side with your photo, name and ID number) so it fills the frame, in good light and without glare.`;
|
|
271
274
|
}
|
|
272
|
-
function measureTextBands(grey, w, h, x0 = 0, aw = w) {
|
|
275
|
+
function measureTextBands(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
|
|
273
276
|
const src = medianStep(grey, w, h, x0, aw) >= NOISY_MEDIAN_STEP ? boxSmooth(grey, w, h, x0, aw) : null;
|
|
274
277
|
const at = src ? (y, x) => src[y * aw + x] : (y, x) => grey[y * w + x0 + x];
|
|
278
|
+
const edgeDelta = edgeDeltaOverride ?? edgeThreshold(at, aw, h);
|
|
275
279
|
const ex = [];
|
|
276
280
|
const ey = [];
|
|
277
281
|
for (let y = 0; y < h; y++) {
|
|
278
282
|
for (let x = 0; x < aw - 1; x++) {
|
|
279
|
-
if (Math.abs(at(y, x + 1) - at(y, x)) >
|
|
283
|
+
if (Math.abs(at(y, x + 1) - at(y, x)) > edgeDelta) {
|
|
280
284
|
ex.push(x);
|
|
281
285
|
ey.push(y);
|
|
282
286
|
}
|
|
@@ -379,6 +383,7 @@ function measureTextBands(grey, w, h, x0 = 0, aw = w) {
|
|
|
379
383
|
height,
|
|
380
384
|
strip: s,
|
|
381
385
|
firstCell: first,
|
|
386
|
+
lastCell: last,
|
|
382
387
|
spanFrac: span / cps,
|
|
383
388
|
coverage: active / span,
|
|
384
389
|
density: inside / (span * CELL * height),
|
|
@@ -414,6 +419,25 @@ function medianStep(grey, w, h, x0, aw) {
|
|
|
414
419
|
}
|
|
415
420
|
return 255;
|
|
416
421
|
}
|
|
422
|
+
function edgeThreshold(at, aw, h) {
|
|
423
|
+
const hist = new Uint32Array(256);
|
|
424
|
+
let n = 0;
|
|
425
|
+
for (let y = 0; y < h; y++) {
|
|
426
|
+
for (let x = 0; x < aw - 1; x++) {
|
|
427
|
+
hist[Math.min(255, Math.round(Math.abs(at(y, x + 1) - at(y, x))))]++;
|
|
428
|
+
n++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
let acc = 0, p99 = 255;
|
|
432
|
+
for (let v = 0; v < 256; v++) {
|
|
433
|
+
acc += hist[v];
|
|
434
|
+
if (acc >= n * 0.99) {
|
|
435
|
+
p99 = v;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return Math.max(EDGE_DELTA_MIN, Math.min(EDGE_DELTA, Math.round(p99 * EDGE_DELTA_P99_FRACTION)));
|
|
440
|
+
}
|
|
417
441
|
function boxSmooth(grey, w, h, x0, aw) {
|
|
418
442
|
const out = new Float32Array(aw * h);
|
|
419
443
|
for (let y = 0; y < h; y++) {
|
|
@@ -459,15 +483,17 @@ function periodicity(profile, glyphHeight) {
|
|
|
459
483
|
}
|
|
460
484
|
return best;
|
|
461
485
|
}
|
|
462
|
-
function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
|
|
463
|
-
const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw);
|
|
486
|
+
function analyseTextLayout(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
|
|
487
|
+
const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw, edgeDeltaOverride);
|
|
464
488
|
const stripCells = Math.ceil(Math.max(1, Math.floor(aw / STRIPS)) / CELL);
|
|
465
489
|
const clean = bands.filter((b) => b.spanFrac >= 0.2 && b.coverage >= 0.35 && b.bleed <= TEXT_MAX_BLEED && b.spanFrac * stripCells * CELL / b.height >= 3);
|
|
466
490
|
const textLines = clusterBands(clean).length;
|
|
467
491
|
let alignedLines = 0;
|
|
468
492
|
for (const b of clean) {
|
|
469
|
-
const
|
|
470
|
-
|
|
493
|
+
const sameStrip = clean.filter((o) => o.strip === b.strip);
|
|
494
|
+
const left = sameStrip.filter((o) => Math.abs(o.firstCell - b.firstCell) <= 1).length;
|
|
495
|
+
const right = sameStrip.filter((o) => Math.abs(o.lastCell - b.lastCell) <= 1).length;
|
|
496
|
+
alignedLines = Math.max(alignedLines, left, right);
|
|
471
497
|
}
|
|
472
498
|
const isMrzLine = (l) => l.bleed <= MRZ_MAX_BLEED && l.periodicity >= MRZ_MIN_PERIODICITY;
|
|
473
499
|
const stacked = (p, q) => {
|
|
@@ -475,19 +501,23 @@ function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
|
|
|
475
501
|
const pitch = q.center - p.center;
|
|
476
502
|
return pitch >= 1.3 * avgH && pitch <= 2.8 * avgH && Math.max(p.height, q.height) / Math.min(p.height, q.height) <= 1.4;
|
|
477
503
|
};
|
|
478
|
-
let
|
|
504
|
+
let hasMrz2 = false;
|
|
479
505
|
let runStart = 0;
|
|
480
506
|
for (let i = 1; i <= fullWidthLines.length; i++) {
|
|
481
507
|
if (i < fullWidthLines.length && stacked(fullWidthLines[i - 1], fullWidthLines[i])) continue;
|
|
482
508
|
const run = fullWidthLines.slice(runStart, i);
|
|
483
509
|
if (run.length >= 2 && run.length <= 3) {
|
|
484
510
|
for (let j = 1; j < run.length; j++) {
|
|
485
|
-
if (isMrzLine(run[j - 1]) && isMrzLine(run[j]))
|
|
511
|
+
if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz2 = true;
|
|
486
512
|
}
|
|
487
513
|
}
|
|
488
514
|
runStart = i;
|
|
489
515
|
}
|
|
490
|
-
return { textLines, alignedLines, hasMrz };
|
|
516
|
+
return { textLines, alignedLines, hasMrz: hasMrz2 };
|
|
517
|
+
}
|
|
518
|
+
var MRZ_RETRY_EDGE_DELTA = 14;
|
|
519
|
+
function hasMrz(grey, w, h, x0 = 0, aw = w) {
|
|
520
|
+
return analyseTextLayout(grey, w, h, x0, aw).hasMrz || analyseTextLayout(grey, w, h, x0, aw, MRZ_RETRY_EDGE_DELTA).hasMrz;
|
|
491
521
|
}
|
|
492
522
|
async function detectText(source) {
|
|
493
523
|
const TextDetectorCtor = globalThis.TextDetector;
|
|
@@ -516,14 +546,14 @@ function laplacianVariance(grey, w, h, x0, aw) {
|
|
|
516
546
|
function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
|
|
517
547
|
const ratio = w / h;
|
|
518
548
|
const regions = ratio > 2 ? [{ x0: 0, aw: Math.floor(w / 2) }, { x0: Math.floor(w / 2), aw: w - Math.floor(w / 2) }] : [{ x0: 0, aw: w }];
|
|
519
|
-
let dataPage = regions.find(({ x0, aw }) =>
|
|
549
|
+
let dataPage = regions.find(({ x0, aw }) => hasMrz(grey, w, h, x0, aw));
|
|
520
550
|
if (!dataPage && browserFoundMrz) {
|
|
521
551
|
dataPage = regions.reduce((best, r) => countTextRows(grey, w, h, r.x0, r.aw) > countTextRows(grey, w, h, best.x0, best.aw) ? r : best);
|
|
522
552
|
}
|
|
523
553
|
if (!dataPage) return [MRZ_NOT_FOUND_MESSAGE];
|
|
524
554
|
if (laplacianVariance(grey, w, h, dataPage.x0, dataPage.aw) < BLUR_THRESHOLD) {
|
|
525
555
|
return [
|
|
526
|
-
"The data page is too blurry
|
|
556
|
+
"The data page is too blurry. Hold the camera steady and make sure the MRZ text at the bottom of the data page is in sharp focus."
|
|
527
557
|
];
|
|
528
558
|
}
|
|
529
559
|
return [];
|
|
@@ -531,6 +561,7 @@ function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
|
|
|
531
561
|
function checkReadableText(grey, w, h, idType, browserTextBlocks = null) {
|
|
532
562
|
if ((browserTextBlocks ?? 0) >= 3) return [];
|
|
533
563
|
const layout = analyseTextLayout(grey, w, h);
|
|
564
|
+
if (layout.hasMrz) return [];
|
|
534
565
|
if (layout.textLines >= MIN_ID_TEXT_LINES && layout.alignedLines >= MIN_ID_ALIGNED_LINES) return [];
|
|
535
566
|
return [idTextNotFoundMessage(idType)];
|
|
536
567
|
}
|
|
@@ -539,7 +570,7 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
|
|
|
539
570
|
const totalTextRows = precomputedTotalTextRows ?? countTextRows(grey, w, h, 0, w);
|
|
540
571
|
if (totalTextRows < Math.max(2, h * 0.02)) {
|
|
541
572
|
return [
|
|
542
|
-
"This does not look like an identity document
|
|
573
|
+
"This does not look like an identity document: no printed text was detected. Please photograph your ID card or passport data page on a plain, well-lit background."
|
|
543
574
|
];
|
|
544
575
|
}
|
|
545
576
|
if (idType === "passport") {
|
|
@@ -555,14 +586,68 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
|
|
|
555
586
|
}
|
|
556
587
|
return [];
|
|
557
588
|
}
|
|
589
|
+
var BACK_MRZ_NOT_FOUND_MESSAGE = "We could not read the machine-readable zone (MRZ) on the back of your card: the 3 lines of letters, numbers and <<< along the bottom. This usually means the photo is blurry or the card is too small in the frame. Fill the frame with the back of the card, hold the phone steady, and wait for the <<< lines to come into focus before capturing. Avoid glare on the card.";
|
|
590
|
+
function checkBackOfCard(grey, w, h, browserFoundMrz = false) {
|
|
591
|
+
let total = 0;
|
|
592
|
+
for (let i = 0; i < grey.length; i++) total += grey[i];
|
|
593
|
+
const brightness = total / grey.length;
|
|
594
|
+
const issues = [];
|
|
595
|
+
if (brightness < DARK_THRESHOLD) issues.push("Image is too dark. Move to a well-lit area.");
|
|
596
|
+
if (brightness > BRIGHT_THRESHOLD)
|
|
597
|
+
issues.push("Image is overexposed. Avoid direct light or glare shining onto the card.");
|
|
598
|
+
const mrzFound = browserFoundMrz || hasMrz(grey, w, h);
|
|
599
|
+
if (mrzFound && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD) {
|
|
600
|
+
issues.push("The back of the card is too blurry. Hold the camera steady so the lines of <<< are sharp.");
|
|
601
|
+
}
|
|
602
|
+
if (!mrzFound && issues.length === 0) issues.push(BACK_MRZ_NOT_FOUND_MESSAGE);
|
|
603
|
+
return { ok: issues.length === 0, issues, hasMrz: mrzFound };
|
|
604
|
+
}
|
|
605
|
+
async function checkBackPhotoQuality(photo) {
|
|
606
|
+
if (photo.size < MIN_FILE_BYTES) {
|
|
607
|
+
return { ok: false, hasMrz: false, issues: ["File is too small. This does not appear to be a real photo."] };
|
|
608
|
+
}
|
|
609
|
+
if (!await hasSupportedMagicBytes(photo)) {
|
|
610
|
+
return { ok: false, hasMrz: false, issues: ["Unsupported file type. Please upload a JPEG or PNG photo."] };
|
|
611
|
+
}
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
const img = new Image();
|
|
614
|
+
const url = URL.createObjectURL(photo);
|
|
615
|
+
img.onerror = () => {
|
|
616
|
+
URL.revokeObjectURL(url);
|
|
617
|
+
resolve({ ok: false, hasMrz: false, issues: ["Could not read the image. Please try a different photo."] });
|
|
618
|
+
};
|
|
619
|
+
img.onload = async () => {
|
|
620
|
+
URL.revokeObjectURL(url);
|
|
621
|
+
const scale = Math.min(1, 640 / Math.max(img.width, img.height));
|
|
622
|
+
const w = Math.round(img.width * scale);
|
|
623
|
+
const h = Math.round(img.height * scale);
|
|
624
|
+
const canvas = document.createElement("canvas");
|
|
625
|
+
canvas.width = w;
|
|
626
|
+
canvas.height = h;
|
|
627
|
+
const ctx = canvas.getContext("2d");
|
|
628
|
+
ctx.imageSmoothingEnabled = false;
|
|
629
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
630
|
+
const textPromise = detectText(canvas);
|
|
631
|
+
const { data } = ctx.getImageData(0, 0, w, h);
|
|
632
|
+
const grey = new Float32Array(w * h);
|
|
633
|
+
for (let i = 0; i < grey.length; i++) {
|
|
634
|
+
grey[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
|
|
635
|
+
}
|
|
636
|
+
const texts = await textPromise;
|
|
637
|
+
const browserFoundMrz = texts?.some((t) => looksLikeMrzLine(t.rawValue ?? "")) ?? false;
|
|
638
|
+
resolve(checkBackOfCard(grey, w, h, browserFoundMrz));
|
|
639
|
+
};
|
|
640
|
+
img.src = url;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
558
643
|
async function checkPhotoQuality(photo, idType = "national_id") {
|
|
559
644
|
const issues = [];
|
|
560
645
|
if (photo.size < MIN_FILE_BYTES) {
|
|
561
|
-
issues.push("File is too small
|
|
646
|
+
issues.push("File is too small. This does not appear to be a real photo.");
|
|
562
647
|
return { ok: false, issues };
|
|
563
648
|
}
|
|
564
649
|
if (!await hasSupportedMagicBytes(photo)) {
|
|
565
|
-
issues.push("Unsupported file type
|
|
650
|
+
issues.push("Unsupported file type. Please upload a JPEG or PNG photo.");
|
|
566
651
|
return { ok: false, issues };
|
|
567
652
|
}
|
|
568
653
|
return new Promise((resolve) => {
|
|
@@ -570,7 +655,7 @@ async function checkPhotoQuality(photo, idType = "national_id") {
|
|
|
570
655
|
const url = URL.createObjectURL(photo);
|
|
571
656
|
img.onerror = () => {
|
|
572
657
|
URL.revokeObjectURL(url);
|
|
573
|
-
resolve({ ok: false, issues: ["Could not read the image
|
|
658
|
+
resolve({ ok: false, issues: ["Could not read the image. Please try a different photo."] });
|
|
574
659
|
};
|
|
575
660
|
img.onload = async () => {
|
|
576
661
|
URL.revokeObjectURL(url);
|
|
@@ -596,11 +681,11 @@ async function checkPhotoQuality(photo, idType = "national_id") {
|
|
|
596
681
|
}
|
|
597
682
|
const brightness = totalLum / len;
|
|
598
683
|
if (idType !== "passport" && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD)
|
|
599
|
-
issues.push("Photo is too blurry
|
|
684
|
+
issues.push("Photo is too blurry. Hold the camera steady and make sure the document is in sharp focus.");
|
|
600
685
|
if (brightness < DARK_THRESHOLD)
|
|
601
|
-
issues.push("Image is too dark
|
|
686
|
+
issues.push("Image is too dark. Move to a well-lit area.");
|
|
602
687
|
if (brightness > BRIGHT_THRESHOLD)
|
|
603
|
-
issues.push("Image is overexposed
|
|
688
|
+
issues.push("Image is overexposed. Avoid direct light or glare shining onto the document.");
|
|
604
689
|
const totalTextRows = countTextRows(grey, w, h, 0, w);
|
|
605
690
|
const identityIssues = checkDocumentIdentity(grey, w, h, idType, totalTextRows);
|
|
606
691
|
issues.push(...identityIssues);
|
|
@@ -672,13 +757,22 @@ function sdkHeaders(sdkToken, livenessFlows) {
|
|
|
672
757
|
}
|
|
673
758
|
async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, livenessFlows) {
|
|
674
759
|
if (!await hasSupportedMagicBytes(options.idPhoto)) {
|
|
675
|
-
throw new ValidationError("Unsupported image format
|
|
760
|
+
throw new ValidationError("Unsupported image format. Only JPEG and PNG photos are accepted.");
|
|
761
|
+
}
|
|
762
|
+
if (options.idPhotoBack) {
|
|
763
|
+
if (options.idType === "passport") {
|
|
764
|
+
throw new ValidationError("idPhotoBack is only accepted for national IDs and driver's licences.");
|
|
765
|
+
}
|
|
766
|
+
if (!await hasSupportedMagicBytes(options.idPhotoBack)) {
|
|
767
|
+
throw new ValidationError("Unsupported image format for the back of the card. Only JPEG and PNG photos are accepted.");
|
|
768
|
+
}
|
|
676
769
|
}
|
|
677
770
|
const build = async () => {
|
|
678
771
|
const form = new FormData();
|
|
679
772
|
form.append("user_id", options.userId);
|
|
680
773
|
form.append("id_type", options.idType);
|
|
681
774
|
form.append("id_photo", options.idPhoto);
|
|
775
|
+
if (options.idPhotoBack) form.append("id_photo_back", options.idPhotoBack);
|
|
682
776
|
form.append("consent_given_at", options.consentGivenAt.toISOString());
|
|
683
777
|
form.append("processing_purpose", options.processingPurpose ?? "identity_verification");
|
|
684
778
|
const signature = signCapture2 ? await signCapture2() : null;
|
|
@@ -690,6 +784,13 @@ async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, live
|
|
|
690
784
|
const res = await postIdempotent(fetchOf(transport), `${apiUrl}/v1/kyc/start`, build, transport?.retry);
|
|
691
785
|
return handleResponse(res);
|
|
692
786
|
}
|
|
787
|
+
async function getCaptureSettings(apiUrl, sdkToken, transport) {
|
|
788
|
+
const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/capture-settings`, {
|
|
789
|
+
headers: { "Authorization": `Bearer ${sdkToken}` }
|
|
790
|
+
});
|
|
791
|
+
if (res.status === 404) return { document_back: "off" };
|
|
792
|
+
return handleResponse(res);
|
|
793
|
+
}
|
|
693
794
|
async function getLivenessCredentials(apiUrl, sdkToken, transport) {
|
|
694
795
|
const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/liveness-credentials`, {
|
|
695
796
|
headers: { "Authorization": `Bearer ${sdkToken}` }
|
|
@@ -829,22 +930,26 @@ async function registerDeviceKey(fetchImpl, apiUrl, token, pair) {
|
|
|
829
930
|
});
|
|
830
931
|
return res.device_key_id;
|
|
831
932
|
}
|
|
832
|
-
function captureMessage(imageSha256Hex, userId, nonce, ts) {
|
|
933
|
+
function captureMessage(imageSha256Hex, userId, nonce, ts, backImageSha256Hex) {
|
|
934
|
+
if (backImageSha256Hex !== void 0) {
|
|
935
|
+
return `attestid-capture-v2|${imageSha256Hex}|${backImageSha256Hex}|${userId}|${nonce}|${ts}`;
|
|
936
|
+
}
|
|
833
937
|
return `attestid-capture-v1|${imageSha256Hex}|${userId}|${nonce}|${ts}`;
|
|
834
938
|
}
|
|
835
|
-
async function signCapture(fetchImpl, apiUrl, token, userId, photo) {
|
|
939
|
+
async function signCapture(fetchImpl, apiUrl, token, userId, photo, backPhoto) {
|
|
836
940
|
try {
|
|
837
941
|
if (!deviceBindingSupported()) return null;
|
|
838
942
|
const pair = await getDeviceKeyPair();
|
|
839
943
|
const deviceKeyId = await registerDeviceKey(fetchImpl, apiUrl, token, pair);
|
|
840
944
|
const imageHash = bytesToHex(await sha256(new Uint8Array(await photo.arrayBuffer())));
|
|
945
|
+
const backHash = backPhoto ? bytesToHex(await sha256(new Uint8Array(await backPhoto.arrayBuffer()))) : void 0;
|
|
841
946
|
const nonce = await challenge(fetchImpl, apiUrl, token);
|
|
842
947
|
const ts = Math.floor(Date.now() / 1e3);
|
|
843
948
|
const sig = new Uint8Array(
|
|
844
949
|
await subtle().sign(
|
|
845
950
|
{ name: "ECDSA", hash: "SHA-256" },
|
|
846
951
|
pair.privateKey,
|
|
847
|
-
buf(utf8(captureMessage(imageHash, userId, nonce, ts)))
|
|
952
|
+
buf(utf8(captureMessage(imageHash, userId, nonce, ts, backHash)))
|
|
848
953
|
)
|
|
849
954
|
);
|
|
850
955
|
return {
|
|
@@ -909,12 +1014,12 @@ function assertSecureApiUrl(apiUrl) {
|
|
|
909
1014
|
try {
|
|
910
1015
|
url = new URL(apiUrl);
|
|
911
1016
|
} catch {
|
|
912
|
-
throw new AttestIDError(`apiUrl must be a valid absolute URL
|
|
1017
|
+
throw new AttestIDError(`apiUrl must be a valid absolute URL, got "${apiUrl}".`, "INSECURE_CONFIG");
|
|
913
1018
|
}
|
|
914
1019
|
if (url.protocol === "https:") return;
|
|
915
1020
|
if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return;
|
|
916
1021
|
throw new AttestIDError(
|
|
917
|
-
`apiUrl must use https
|
|
1022
|
+
`apiUrl must use https://, got "${apiUrl}". Sending it over plain http exposes your API key, SDK token, and uploaded ID photos to anyone on the network path. http://localhost (or 127.0.0.1/::1) is allowed for local development against a backend running without TLS.`,
|
|
918
1023
|
"INSECURE_CONFIG"
|
|
919
1024
|
);
|
|
920
1025
|
}
|
|
@@ -931,7 +1036,7 @@ var AttestID = class {
|
|
|
931
1036
|
assertSecureApiUrl(config.apiUrl);
|
|
932
1037
|
if (isServerConfig(config) && !isTrustedServerEnvironment()) {
|
|
933
1038
|
throw new AttestIDError(
|
|
934
|
-
"apiKey/accessKeyId must only be used in a trusted Node.js backend process
|
|
1039
|
+
"apiKey/accessKeyId must only be used in a trusted Node.js backend process. A bundler would ship this long-lived secret to every client, letting anyone extract it. In browser or mobile app code, configure `getSdkToken` instead: a callback that fetches an already-minted sdk_token from your own backend, which holds apiKey server-side and calls POST /kyc/session. See the integration guide for the backend-proxy pattern.",
|
|
935
1040
|
"INSECURE_CONFIG"
|
|
936
1041
|
);
|
|
937
1042
|
}
|
|
@@ -960,7 +1065,7 @@ var AttestID = class {
|
|
|
960
1065
|
const minted = sessionPromise.then((session) => {
|
|
961
1066
|
if (!session?.sdk_token) {
|
|
962
1067
|
throw new AuthError(
|
|
963
|
-
"getSdkToken() did not return a valid sdk_token
|
|
1068
|
+
"getSdkToken() did not return a valid sdk_token. Check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
|
|
964
1069
|
);
|
|
965
1070
|
}
|
|
966
1071
|
this.tokenExpiresAt = Date.now() + session.expires_in * 1e3;
|
|
@@ -990,16 +1095,25 @@ var AttestID = class {
|
|
|
990
1095
|
async startVerification(options) {
|
|
991
1096
|
if (!options.consentGivenAt) {
|
|
992
1097
|
throw new AttestIDError(
|
|
993
|
-
"consentGivenAt is required
|
|
1098
|
+
"consentGivenAt is required: capture the timestamp of an explicit user consent action before starting verification",
|
|
994
1099
|
"CONSENT_REQUIRED"
|
|
995
1100
|
);
|
|
996
1101
|
}
|
|
997
1102
|
const token = await this.getSdkToken(options.userId);
|
|
998
1103
|
const { apiUrl } = this.config;
|
|
999
1104
|
const fetchImpl = this.transport.fetch ?? ((input, init) => fetch(input, init));
|
|
1000
|
-
const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto) : void 0;
|
|
1105
|
+
const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto, options.idPhotoBack) : void 0;
|
|
1001
1106
|
return startKYC(apiUrl, token, options, this.transport, signer, this.livenessFlows);
|
|
1002
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* The company's capture settings, set on the AttestID dashboard. Read
|
|
1110
|
+
* `document_back` before the document step to know whether to ask for the
|
|
1111
|
+
* back of a national ID / driver's licence (see DocumentBackPolicy).
|
|
1112
|
+
*/
|
|
1113
|
+
async getCaptureSettings(userId) {
|
|
1114
|
+
const token = await this.getSdkToken(userId);
|
|
1115
|
+
return getCaptureSettings(this.config.apiUrl, token, this.transport);
|
|
1116
|
+
}
|
|
1003
1117
|
/**
|
|
1004
1118
|
* Fetch temporary AWS credentials and the Cognito identity pool ID from the
|
|
1005
1119
|
* backend. Call this alongside startVerification() for the same userId to
|
|
@@ -2317,6 +2431,7 @@ function clearHandoffParamsFromAddressBar() {
|
|
|
2317
2431
|
buildHandoffUrl,
|
|
2318
2432
|
canRetryLiveness,
|
|
2319
2433
|
canonicalJson,
|
|
2434
|
+
checkBackPhotoQuality,
|
|
2320
2435
|
checkCaptureConditions,
|
|
2321
2436
|
checkPhotoQuality,
|
|
2322
2437
|
classifyConditions,
|