@dpdpguard/react-native 1.3.0 → 2.0.0
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/DpdpGuardReactNative.podspec +35 -0
- package/README.md +128 -24
- package/android/build.gradle +45 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/dpdpguard/offline/DpdpGuardOfflineModule.kt +317 -0
- package/android/src/main/java/ai/dpdpguard/offline/DpdpGuardOfflinePackage.kt +33 -0
- package/ios/DpdpGuardOffline.h +18 -0
- package/ios/DpdpGuardOffline.mm +481 -0
- package/ios/DpdpGuardOfflineCrypto.swift +27 -0
- package/package.json +22 -3
- package/src/NativeDpdpGuardOffline.ts +64 -0
- package/src/index.ts +33 -0
- package/src/offline/OfflineCaptureManager.ts +249 -0
- package/src/offline/captureArtifact.ts +184 -0
- package/src/offline/types.ts +134 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { canonicalCaptureString } from "./captureArtifact";
|
|
2
|
+
import type { Spec as OfflineNativeSpec } from "../NativeDpdpGuardOffline";
|
|
3
|
+
import type {
|
|
4
|
+
CaptureInput,
|
|
5
|
+
OfflineCaptureResult,
|
|
6
|
+
OfflineCaptureSession,
|
|
7
|
+
OfflineCaptureSyncBody,
|
|
8
|
+
OfflineCaptureTransport,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
/** `POST /api/v1/offline/captures` rejects a batch larger than this. */
|
|
12
|
+
export const MAX_SYNC_BATCH = 200;
|
|
13
|
+
|
|
14
|
+
export interface OfflineCaptureManagerOptions {
|
|
15
|
+
organizationId: string;
|
|
16
|
+
/** The enrolled `agentDevices` row id for this device. */
|
|
17
|
+
deviceId: string;
|
|
18
|
+
/**
|
|
19
|
+
* The native binding. Defaults to the `DpdpGuardOffline` TurboModule;
|
|
20
|
+
* inject a fake in tests, or to run the capture flow on a platform
|
|
21
|
+
* where the native module isn't installed.
|
|
22
|
+
*/
|
|
23
|
+
native?: OfflineNativeSpec;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Device-side offline consent capture — the client half of
|
|
28
|
+
* `docs/specs/offline-consent-capture.md` §4.
|
|
29
|
+
*
|
|
30
|
+
* {@link OfflineCaptureManager.capture} does no network I/O at all: it
|
|
31
|
+
* builds the `dpdpcca/2` canonical string, has the secure element sign it,
|
|
32
|
+
* and persists the session to the encrypted local store.
|
|
33
|
+
* {@link OfflineCaptureManager.syncPending} drains that store when
|
|
34
|
+
* connectivity returns and evicts every session the server acknowledges.
|
|
35
|
+
*/
|
|
36
|
+
export class OfflineCaptureManager {
|
|
37
|
+
private readonly organizationId: string;
|
|
38
|
+
private readonly deviceId: string;
|
|
39
|
+
private readonly native: OfflineNativeSpec;
|
|
40
|
+
private sequence = 0;
|
|
41
|
+
|
|
42
|
+
constructor(options: OfflineCaptureManagerOptions) {
|
|
43
|
+
this.organizationId = options.organizationId;
|
|
44
|
+
this.deviceId = options.deviceId;
|
|
45
|
+
// The native module is required lazily so that importing this package
|
|
46
|
+
// on a platform without it (a plain Node test runner, web) doesn't
|
|
47
|
+
// throw at import time — only constructing a manager without an
|
|
48
|
+
// injected `native` does.
|
|
49
|
+
this.native =
|
|
50
|
+
options.native ??
|
|
51
|
+
(require("../NativeDpdpGuardOffline").default as OfflineNativeSpec);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates the device's secure-element signing key if needed and returns
|
|
56
|
+
* its base64 SPKI DER public key, plus whether the key is actually
|
|
57
|
+
* hardware-backed. Enroll that public key on `agentDevices` through the
|
|
58
|
+
* fleet-enrollment console flow before any capture will verify.
|
|
59
|
+
*/
|
|
60
|
+
async enroll(): Promise<{
|
|
61
|
+
publicKeySPKIBase64: string;
|
|
62
|
+
hardwareBacked: boolean;
|
|
63
|
+
}> {
|
|
64
|
+
const publicKeySPKIBase64 = await this.native.enrollDevice();
|
|
65
|
+
const hardwareBacked = await this.native.isHardwareBacked();
|
|
66
|
+
return { publicKeySPKIBase64, hardwareBacked };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Signs and queues one capture. Works at zero bars — nothing here
|
|
71
|
+
* touches the network.
|
|
72
|
+
*/
|
|
73
|
+
async capture(input: CaptureInput): Promise<OfflineCaptureSession> {
|
|
74
|
+
const clientSessionId = input.clientSessionId ?? generateUuidV4();
|
|
75
|
+
const capturedAtClient = input.capturedAtClient ?? Date.now();
|
|
76
|
+
|
|
77
|
+
const canonical = canonicalCaptureString({
|
|
78
|
+
clientSessionId,
|
|
79
|
+
organizationId: this.organizationId,
|
|
80
|
+
deviceId: this.deviceId,
|
|
81
|
+
noticeId: input.noticeId,
|
|
82
|
+
noticeVersion: input.noticeVersion,
|
|
83
|
+
noticeLocale: input.noticeLocale,
|
|
84
|
+
purposeGrants: input.purposeGrants.map((grant) => ({
|
|
85
|
+
purposeKey: grant.purposeKey,
|
|
86
|
+
status: grant.status,
|
|
87
|
+
})),
|
|
88
|
+
dataTypes: input.dataTypes,
|
|
89
|
+
principalHandleKind: input.principalHandle?.kind ?? "none",
|
|
90
|
+
principalHandleValue: input.principalHandle?.value ?? "",
|
|
91
|
+
capturedAtClient,
|
|
92
|
+
channel: input.channel,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const deviceSignature = await this.native.sign(canonical);
|
|
96
|
+
|
|
97
|
+
this.sequence += 1;
|
|
98
|
+
const session: OfflineCaptureSession = {
|
|
99
|
+
clientSessionId,
|
|
100
|
+
organizationId: this.organizationId,
|
|
101
|
+
deviceId: this.deviceId,
|
|
102
|
+
noticeId: input.noticeId,
|
|
103
|
+
noticeVersion: input.noticeVersion,
|
|
104
|
+
noticeLocale: input.noticeLocale,
|
|
105
|
+
channel: input.channel,
|
|
106
|
+
capturedAtClient,
|
|
107
|
+
captureSequence: this.sequence,
|
|
108
|
+
purposeGrants: input.purposeGrants,
|
|
109
|
+
dataTypes: input.dataTypes,
|
|
110
|
+
canonical,
|
|
111
|
+
deviceSignature,
|
|
112
|
+
...(input.principalHandle
|
|
113
|
+
? { principalHandle: input.principalHandle }
|
|
114
|
+
: {}),
|
|
115
|
+
...(input.linkId ? { linkId: input.linkId } : {}),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
await this.native.saveSession(clientSessionId, JSON.stringify(session));
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every queued session, oldest capture first. */
|
|
123
|
+
async pendingSessions(): Promise<OfflineCaptureSession[]> {
|
|
124
|
+
const raw = await this.native.loadPendingSessions();
|
|
125
|
+
const sessions: OfflineCaptureSession[] = [];
|
|
126
|
+
for (const entry of raw) {
|
|
127
|
+
try {
|
|
128
|
+
sessions.push(JSON.parse(entry) as OfflineCaptureSession);
|
|
129
|
+
} catch {
|
|
130
|
+
// A corrupt entry is skipped rather than failing the whole
|
|
131
|
+
// drain — the native store reports these separately.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return sessions.sort((a, b) => a.capturedAtClient - b.capturedAtClient);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Drains the local store through `transport`, in batches of at most
|
|
139
|
+
* {@link MAX_SYNC_BATCH}, and evicts every session the server
|
|
140
|
+
* acknowledged.
|
|
141
|
+
*
|
|
142
|
+
* All three statuses are terminal acknowledgements: a `quarantined` or
|
|
143
|
+
* `rejected` session is retained *server-side* as evidence (spec §4.4 —
|
|
144
|
+
* never silently dropped), so keeping a local copy would only mean
|
|
145
|
+
* re-submitting it forever.
|
|
146
|
+
*
|
|
147
|
+
* Partial batch success is normal and is not an error: inspect the
|
|
148
|
+
* returned results rather than assuming a resolved promise means every
|
|
149
|
+
* capture committed.
|
|
150
|
+
*/
|
|
151
|
+
async syncPending(
|
|
152
|
+
transport: OfflineCaptureTransport,
|
|
153
|
+
): Promise<OfflineCaptureResult[]> {
|
|
154
|
+
const pending = await this.pendingSessions();
|
|
155
|
+
const results: OfflineCaptureResult[] = [];
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < pending.length; i += MAX_SYNC_BATCH) {
|
|
158
|
+
const batch = pending.slice(i, i + MAX_SYNC_BATCH);
|
|
159
|
+
const response = await transport(toSyncBody(batch));
|
|
160
|
+
const batchResults = response.results ?? [];
|
|
161
|
+
results.push(...batchResults);
|
|
162
|
+
|
|
163
|
+
for (const result of batchResults) {
|
|
164
|
+
await this.native.removeSession(result.clientSessionId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Drops every queued session without syncing. */
|
|
172
|
+
clearPending(): Promise<void> {
|
|
173
|
+
return this.native.removeAllSessions();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Maps queued sessions to the `POST /api/v1/offline/captures` body.
|
|
179
|
+
* `organizationId`, `canonical`, and `captureSequence` are local-only — the
|
|
180
|
+
* server derives the org from the API key and recomputes the canonical
|
|
181
|
+
* string itself.
|
|
182
|
+
*/
|
|
183
|
+
export function toSyncBody(
|
|
184
|
+
sessions: OfflineCaptureSession[],
|
|
185
|
+
): OfflineCaptureSyncBody {
|
|
186
|
+
return {
|
|
187
|
+
sessions: sessions.map((session) => ({
|
|
188
|
+
clientSessionId: session.clientSessionId,
|
|
189
|
+
deviceId: session.deviceId,
|
|
190
|
+
noticeId: session.noticeId,
|
|
191
|
+
noticeVersion: session.noticeVersion,
|
|
192
|
+
noticeLocale: session.noticeLocale,
|
|
193
|
+
channel: session.channel,
|
|
194
|
+
capturedAtClient: session.capturedAtClient,
|
|
195
|
+
deviceSignature: session.deviceSignature,
|
|
196
|
+
dataTypes: session.dataTypes,
|
|
197
|
+
purposeGrants: session.purposeGrants,
|
|
198
|
+
...(session.principalHandle
|
|
199
|
+
? { principalHandle: session.principalHandle }
|
|
200
|
+
: {}),
|
|
201
|
+
...(session.linkId ? { linkId: session.linkId } : {}),
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* UUIDv4 for `clientSessionId`. This is an idempotency key, not a secret,
|
|
208
|
+
* but it still prefers a CSPRNG where one exists; the `Math.random`
|
|
209
|
+
* fallback only runs on a JS engine exposing neither `crypto.randomUUID`
|
|
210
|
+
* nor `crypto.getRandomValues`.
|
|
211
|
+
*/
|
|
212
|
+
function generateUuidV4(): string {
|
|
213
|
+
const cryptoObj = (
|
|
214
|
+
globalThis as {
|
|
215
|
+
crypto?: {
|
|
216
|
+
randomUUID?: () => string;
|
|
217
|
+
getRandomValues?: (array: Uint8Array) => Uint8Array;
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
).crypto;
|
|
221
|
+
|
|
222
|
+
if (typeof cryptoObj?.randomUUID === "function") {
|
|
223
|
+
return cryptoObj.randomUUID();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const bytes = new Uint8Array(16);
|
|
227
|
+
if (typeof cryptoObj?.getRandomValues === "function") {
|
|
228
|
+
cryptoObj.getRandomValues(bytes);
|
|
229
|
+
} else {
|
|
230
|
+
for (let i = 0; i < 16; i += 1) {
|
|
231
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
236
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
237
|
+
|
|
238
|
+
const hex: string[] = [];
|
|
239
|
+
for (let i = 0; i < 16; i += 1) {
|
|
240
|
+
hex.push(bytes[i].toString(16).padStart(2, "0"));
|
|
241
|
+
}
|
|
242
|
+
return [
|
|
243
|
+
hex.slice(0, 4).join(""),
|
|
244
|
+
hex.slice(4, 6).join(""),
|
|
245
|
+
hex.slice(6, 8).join(""),
|
|
246
|
+
hex.slice(8, 10).join(""),
|
|
247
|
+
hex.slice(10, 16).join(""),
|
|
248
|
+
].join("-");
|
|
249
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cryptographic Consent Artifact canonicalization — `dpdpcca/2`, from
|
|
3
|
+
* `@dpdpguard/contract`'s `conformance/audit-hash-spec.md` and
|
|
4
|
+
* `docs/specs/offline-consent-capture.md` §4.2 — pinned byte-for-byte by
|
|
5
|
+
* `conformance/capture-artifact-vectors.json`.
|
|
6
|
+
*
|
|
7
|
+
* This is the one part of offline capture that must be identical across
|
|
8
|
+
* every SDK and the server: a device signs these exact bytes, and the
|
|
9
|
+
* server re-derives them at sync. A single byte of drift quarantines a
|
|
10
|
+
* legitimate consent as `signature_mismatch`. It therefore lives here, in
|
|
11
|
+
* JavaScript, tested against the golden vectors — the native modules under
|
|
12
|
+
* `android/` and `ios/` do key storage, signing, and encrypted persistence
|
|
13
|
+
* only, and never reimplement this string.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Wire values for `offlineCaptureSessions.channel`. */
|
|
17
|
+
export type CaptureChannel = "qr" | "agent_app" | "pos" | "async_link" | "ivr";
|
|
18
|
+
|
|
19
|
+
/** Per-purpose decision recorded in a capture. */
|
|
20
|
+
export type PurposeGrantStatus = "opt_in" | "opt_out";
|
|
21
|
+
|
|
22
|
+
/** How the Data Principal was identified at capture time. */
|
|
23
|
+
export type PrincipalHandleKind = "phone_hash" | "uhid" | "cbs_ref" | "none";
|
|
24
|
+
|
|
25
|
+
export interface PurposeGrant {
|
|
26
|
+
purposeKey: string;
|
|
27
|
+
status: PurposeGrantStatus;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The exact fields covered by the device signature. */
|
|
31
|
+
export interface CaptureArtifactInput {
|
|
32
|
+
/** Client-generated UUIDv4; also the sync idempotency key. */
|
|
33
|
+
clientSessionId: string;
|
|
34
|
+
organizationId: string;
|
|
35
|
+
/** The `agentDevices` row whose public key must verify this signature. */
|
|
36
|
+
deviceId: string;
|
|
37
|
+
noticeId: string;
|
|
38
|
+
noticeVersion: number;
|
|
39
|
+
/** The Eighth Schedule locale actually rendered — s. 5(3) evidence. */
|
|
40
|
+
noticeLocale: string;
|
|
41
|
+
purposeGrants: PurposeGrant[];
|
|
42
|
+
dataTypes: string[];
|
|
43
|
+
principalHandleKind: PrincipalHandleKind;
|
|
44
|
+
/**
|
|
45
|
+
* The **raw** handle as collected — signed raw so an SDK need not
|
|
46
|
+
* reimplement the platform's HMAC. Empty serializes as the literal
|
|
47
|
+
* `"null"`.
|
|
48
|
+
*/
|
|
49
|
+
principalHandleValue: string;
|
|
50
|
+
/** Device clock. Context, never authority (spec §4.3). */
|
|
51
|
+
capturedAtClient: number;
|
|
52
|
+
channel: CaptureChannel;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The literal first token of every canonical string. */
|
|
56
|
+
export const CAPTURE_ARTIFACT_VERSION = "dpdpcca/2";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* UTF-8 bytes of `value`. Hand-rolled rather than using `TextEncoder` so
|
|
60
|
+
* this behaves identically on every JS engine React Native ships with,
|
|
61
|
+
* including older Hermes builds where `TextEncoder` is absent.
|
|
62
|
+
*/
|
|
63
|
+
export function utf8Bytes(value: string): number[] {
|
|
64
|
+
const out: number[] = [];
|
|
65
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
66
|
+
let codePoint = value.charCodeAt(i);
|
|
67
|
+
|
|
68
|
+
// Combine a surrogate pair into its single code point.
|
|
69
|
+
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < value.length) {
|
|
70
|
+
const low = value.charCodeAt(i + 1);
|
|
71
|
+
if (low >= 0xdc00 && low <= 0xdfff) {
|
|
72
|
+
codePoint = (codePoint - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000;
|
|
73
|
+
i += 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (codePoint < 0x80) {
|
|
78
|
+
out.push(codePoint);
|
|
79
|
+
} else if (codePoint < 0x800) {
|
|
80
|
+
out.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
|
|
81
|
+
} else if (codePoint < 0x10000) {
|
|
82
|
+
out.push(
|
|
83
|
+
0xe0 | (codePoint >> 12),
|
|
84
|
+
0x80 | ((codePoint >> 6) & 0x3f),
|
|
85
|
+
0x80 | (codePoint & 0x3f),
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
out.push(
|
|
89
|
+
0xf0 | (codePoint >> 18),
|
|
90
|
+
0x80 | ((codePoint >> 12) & 0x3f),
|
|
91
|
+
0x80 | ((codePoint >> 6) & 0x3f),
|
|
92
|
+
0x80 | (codePoint & 0x3f),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Token length is counted in UTF-8 bytes — not characters, not UTF-16 units. */
|
|
100
|
+
export function utf8ByteLength(value: string): number {
|
|
101
|
+
return utf8Bytes(value).length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Byte-wise UTF-8 ordering.
|
|
106
|
+
*
|
|
107
|
+
* Not `String.prototype.sort`'s default: that compares UTF-16 code units,
|
|
108
|
+
* which disagrees with UTF-8 byte order for anything above the BMP (a
|
|
109
|
+
* surrogate pair sorts below U+E000–U+FFFF, its UTF-8 bytes sort above).
|
|
110
|
+
* The sorts are load-bearing — a client that listed its purposes in a
|
|
111
|
+
* different order on retry would otherwise produce a different canonical
|
|
112
|
+
* string and quarantine a legitimate consent.
|
|
113
|
+
*/
|
|
114
|
+
export function compareUtf8(a: string, b: string): number {
|
|
115
|
+
const left = utf8Bytes(a);
|
|
116
|
+
const right = utf8Bytes(b);
|
|
117
|
+
const shared = Math.min(left.length, right.length);
|
|
118
|
+
for (let i = 0; i < shared; i += 1) {
|
|
119
|
+
if (left[i] !== right[i]) return left[i] - right[i];
|
|
120
|
+
}
|
|
121
|
+
return left.length - right.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function frame(value: string): string {
|
|
125
|
+
return `${utf8ByteLength(value)}:${value}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Builds the canonical string whose UTF-8 bytes the device signs with
|
|
130
|
+
* ECDSA P-256 / SHA-256.
|
|
131
|
+
*
|
|
132
|
+
* Every token is framed as `<utf8ByteLength>:<value>` and joined with `|`;
|
|
133
|
+
* lists carry an explicit framed count before their framed elements.
|
|
134
|
+
* `purposeGrants` sorts by `purposeKey` and `dataTypes` sorts by value,
|
|
135
|
+
* both byte-wise. Neither input array is mutated.
|
|
136
|
+
*
|
|
137
|
+
* @throws if two grants share a `purposeKey` — duplicates are rejected at
|
|
138
|
+
* sync and never collapsed, so failing here turns a guaranteed
|
|
139
|
+
* server-side rejection into an immediate, debuggable client error.
|
|
140
|
+
*/
|
|
141
|
+
export function canonicalCaptureString(input: CaptureArtifactInput): string {
|
|
142
|
+
const grants = [...input.purposeGrants].sort((a, b) =>
|
|
143
|
+
compareUtf8(a.purposeKey, b.purposeKey),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
for (let i = 1; i < grants.length; i += 1) {
|
|
147
|
+
if (grants[i].purposeKey === grants[i - 1].purposeKey) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`canonicalCaptureString: duplicate purposeKey "${grants[i].purposeKey}" — duplicates are rejected at sync, never collapsed.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const dataTypes = [...input.dataTypes].sort(compareUtf8);
|
|
155
|
+
|
|
156
|
+
const tokens: string[] = [
|
|
157
|
+
frame(CAPTURE_ARTIFACT_VERSION),
|
|
158
|
+
frame(input.clientSessionId),
|
|
159
|
+
frame(input.organizationId),
|
|
160
|
+
frame(input.deviceId),
|
|
161
|
+
frame(input.noticeId),
|
|
162
|
+
frame(String(input.noticeVersion)),
|
|
163
|
+
frame(input.noticeLocale),
|
|
164
|
+
frame(String(grants.length)),
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
for (const grant of grants) {
|
|
168
|
+
tokens.push(frame(grant.purposeKey), frame(grant.status));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
tokens.push(frame(String(dataTypes.length)));
|
|
172
|
+
for (const dataType of dataTypes) {
|
|
173
|
+
tokens.push(frame(dataType));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
tokens.push(
|
|
177
|
+
frame(input.principalHandleKind),
|
|
178
|
+
frame(input.principalHandleValue.length > 0 ? input.principalHandleValue : "null"),
|
|
179
|
+
frame(String(input.capturedAtClient)),
|
|
180
|
+
frame(input.channel),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return tokens.join("|");
|
|
184
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CaptureChannel,
|
|
3
|
+
PrincipalHandleKind,
|
|
4
|
+
PurposeGrantStatus,
|
|
5
|
+
} from "./captureArtifact";
|
|
6
|
+
|
|
7
|
+
/** Whether a purpose is one the service cannot run without. */
|
|
8
|
+
export type PurposeCategory = "essential" | "non_essential";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A per-purpose decision as it goes over the wire. Note only `purposeKey`
|
|
12
|
+
* and `status` are covered by the device signature — `purposeLabel` and
|
|
13
|
+
* `category` are carried for the server's records and are not part of the
|
|
14
|
+
* `dpdpcca/2` canonical string.
|
|
15
|
+
*/
|
|
16
|
+
export interface CapturePurposeGrant {
|
|
17
|
+
purposeKey: string;
|
|
18
|
+
purposeLabel: string;
|
|
19
|
+
category: PurposeCategory;
|
|
20
|
+
status: PurposeGrantStatus;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The identity the principal can later withdraw by. */
|
|
24
|
+
export interface PrincipalHandle {
|
|
25
|
+
kind: PrincipalHandleKind;
|
|
26
|
+
/**
|
|
27
|
+
* The raw handle as collected. Signed raw so an SDK need not
|
|
28
|
+
* reimplement the platform's HMAC; a `phone_hash` value is HMAC'd
|
|
29
|
+
* per-organization server-side and the raw number is never persisted.
|
|
30
|
+
*/
|
|
31
|
+
value: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a host app passes to {@link OfflineCaptureManager.capture}. */
|
|
35
|
+
export interface CaptureInput {
|
|
36
|
+
noticeId: string;
|
|
37
|
+
noticeVersion: number;
|
|
38
|
+
/** The Eighth Schedule locale actually rendered to the principal. */
|
|
39
|
+
noticeLocale: string;
|
|
40
|
+
/**
|
|
41
|
+
* Every purpose the principal was *shown*, opted in or out. Declined
|
|
42
|
+
* purposes are submitted too: proving someone was shown marketing and
|
|
43
|
+
* refused is what distinguishes granular consent from a notice that
|
|
44
|
+
* quietly omitted it.
|
|
45
|
+
*/
|
|
46
|
+
purposeGrants: CapturePurposeGrant[];
|
|
47
|
+
dataTypes: string[];
|
|
48
|
+
principalHandle?: PrincipalHandle;
|
|
49
|
+
channel: CaptureChannel;
|
|
50
|
+
/** Set for QR-originated captures only. */
|
|
51
|
+
linkId?: string;
|
|
52
|
+
/** Defaults to a generated UUIDv4. Also the sync idempotency key. */
|
|
53
|
+
clientSessionId?: string;
|
|
54
|
+
/** Defaults to `Date.now()`. Context, never authority (spec §4.3). */
|
|
55
|
+
capturedAtClient?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A signed, persisted capture awaiting sync.
|
|
60
|
+
*
|
|
61
|
+
* `canonical` is stored alongside the signature deliberately: the
|
|
62
|
+
* signature is only meaningful against the exact bytes that were signed,
|
|
63
|
+
* so keeping them means a session stays re-verifiable even if this SDK
|
|
64
|
+
* later moves to a newer canonicalization version.
|
|
65
|
+
*/
|
|
66
|
+
export interface OfflineCaptureSession {
|
|
67
|
+
clientSessionId: string;
|
|
68
|
+
organizationId: string;
|
|
69
|
+
deviceId: string;
|
|
70
|
+
noticeId: string;
|
|
71
|
+
noticeVersion: number;
|
|
72
|
+
noticeLocale: string;
|
|
73
|
+
channel: CaptureChannel;
|
|
74
|
+
capturedAtClient: number;
|
|
75
|
+
/** Boot-relative ordering counter — see spec §4.3 on clock trust. */
|
|
76
|
+
captureSequence: number;
|
|
77
|
+
purposeGrants: CapturePurposeGrant[];
|
|
78
|
+
dataTypes: string[];
|
|
79
|
+
principalHandle?: PrincipalHandle;
|
|
80
|
+
linkId?: string;
|
|
81
|
+
/** The exact `dpdpcca/2` string that was signed. */
|
|
82
|
+
canonical: string;
|
|
83
|
+
/** base64 of the raw IEEE-P1363 `r||s` ECDSA P-256 signature. */
|
|
84
|
+
deviceSignature: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One session's outcome from `POST /api/v1/offline/captures`. */
|
|
88
|
+
export interface OfflineCaptureResult {
|
|
89
|
+
clientSessionId: string;
|
|
90
|
+
status: "verified" | "quarantined" | "rejected";
|
|
91
|
+
consentId?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Present for a non-verified session — `unknown_device`,
|
|
94
|
+
* `device_revoked`, `signature_mismatch`, `clock_skew`,
|
|
95
|
+
* `notice_unpublished`, `notice_superseded`, or `invalid_session`.
|
|
96
|
+
*/
|
|
97
|
+
reason?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Present when a consent receipt was issued. Hand this to the principal
|
|
100
|
+
* — it is what makes the consent withdrawable under s. 6(4).
|
|
101
|
+
*/
|
|
102
|
+
withdrawalToken?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The request body `POST /api/v1/offline/captures` expects. */
|
|
106
|
+
export interface OfflineCaptureSyncBody {
|
|
107
|
+
sessions: {
|
|
108
|
+
clientSessionId: string;
|
|
109
|
+
deviceId: string;
|
|
110
|
+
noticeId: string;
|
|
111
|
+
noticeVersion: number;
|
|
112
|
+
noticeLocale: string;
|
|
113
|
+
channel: CaptureChannel;
|
|
114
|
+
capturedAtClient: number;
|
|
115
|
+
deviceSignature: string;
|
|
116
|
+
dataTypes: string[];
|
|
117
|
+
purposeGrants: CapturePurposeGrant[];
|
|
118
|
+
principalHandle?: PrincipalHandle;
|
|
119
|
+
linkId?: string;
|
|
120
|
+
}[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How a batch actually reaches DPDP Guard.
|
|
125
|
+
*
|
|
126
|
+
* `POST /api/v1/offline/captures` is declared `security: [apiKey]` — an
|
|
127
|
+
* org-scoped service key. This SDK never holds one (see README.md's
|
|
128
|
+
* "What's here vs. what's not"), so it cannot call that endpoint itself.
|
|
129
|
+
* The host app supplies this function; in practice it POSTs `body` to its
|
|
130
|
+
* own backend, which adds the API key and forwards it unchanged.
|
|
131
|
+
*/
|
|
132
|
+
export type OfflineCaptureTransport = (
|
|
133
|
+
body: OfflineCaptureSyncBody,
|
|
134
|
+
) => Promise<{ results: OfflineCaptureResult[] }>;
|