@oxy.so/contracts 1.2.0 → 1.4.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/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/identity.js +3 -2
- package/dist/cjs/identityMove.js +111 -40
- package/dist/cjs/identityProof.js +164 -0
- package/dist/cjs/identityRecovery.js +51 -0
- package/dist/cjs/index.js +52 -21
- package/dist/cjs/inference/catalogue.js +23 -1
- package/dist/cjs/inference/identifiers.js +3 -1
- package/dist/cjs/inference/providerConnection.js +1 -1
- package/dist/cjs/inference/request.js +27 -1
- package/dist/cjs/inference/streamEvents.js +24 -2
- package/dist/cjs/inference/version.js +1 -1
- package/dist/cjs/username.js +70 -2
- package/dist/cjs/webIdentityCarrier.js +112 -53
- package/dist/cjs/webauthn.js +14 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/identity.js +3 -2
- package/dist/esm/identityMove.js +105 -39
- package/dist/esm/identityProof.js +159 -0
- package/dist/esm/identityRecovery.js +48 -0
- package/dist/esm/index.js +12 -9
- package/dist/esm/inference/catalogue.js +22 -0
- package/dist/esm/inference/identifiers.js +2 -0
- package/dist/esm/inference/providerConnection.js +2 -2
- package/dist/esm/inference/request.js +27 -1
- package/dist/esm/inference/streamEvents.js +24 -2
- package/dist/esm/inference/version.js +1 -1
- package/dist/esm/username.js +69 -1
- package/dist/esm/webIdentityCarrier.js +111 -52
- package/dist/esm/webauthn.js +14 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/accountGraph.d.ts +7 -7
- package/dist/types/agency.d.ts +24 -24
- package/dist/types/browserHub.d.ts +16 -16
- package/dist/types/deviceDirectory.d.ts +28 -28
- package/dist/types/externalIdentity.d.ts +7 -7
- package/dist/types/identity.d.ts +3 -2
- package/dist/types/identityMove.d.ts +119 -43
- package/dist/types/identityProof.d.ts +156 -0
- package/dist/types/identityRecovery.d.ts +246 -0
- package/dist/types/index.d.ts +15 -13
- package/dist/types/inference/catalogue.d.ts +51 -0
- package/dist/types/inference/identifiers.d.ts +2 -0
- package/dist/types/inference/modelDocumentation.d.ts +12 -0
- package/dist/types/inference/providerConnection.d.ts +40 -40
- package/dist/types/inference/request.d.ts +119 -36
- package/dist/types/inference/streamEvents.d.ts +57 -1
- package/dist/types/inference/version.d.ts +1 -1
- package/dist/types/keyRotation.d.ts +2 -2
- package/dist/types/oauth.d.ts +30 -30
- package/dist/types/sessionStatus.d.ts +6 -6
- package/dist/types/transparency.d.ts +10 -10
- package/dist/types/userResponse.d.ts +18 -18
- package/dist/types/username.d.ts +25 -2
- package/dist/types/webIdentityCarrier.d.ts +767 -159
- package/dist/types/webauthn.d.ts +208 -0
- package/package.json +2 -2
- package/dist/cjs/devicePairing.js +0 -138
- package/dist/esm/devicePairing.js +0 -135
- package/dist/types/devicePairing.d.ts +0 -130
package/dist/cjs/identity.js
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* - DID = `did:web:oxy.so:u:<userId>` — anchored on the stable account id, NOT
|
|
16
16
|
* the keypair. The keypair is a *verification method* that maps 1:1 to the
|
|
17
17
|
* existing `authMethods[]`. Custodial (password-only) users get a DID
|
|
18
|
-
* controlled solely by Oxy (`OXY_DID`);
|
|
19
|
-
*
|
|
18
|
+
* controlled solely by Oxy (`OXY_DID`); linking a root makes them
|
|
19
|
+
* self-sovereign (`controller = [userDid]`, ADR 0024 D9). A root is never
|
|
20
|
+
* unlinked back into a custodial account.
|
|
20
21
|
* - Verification methods use the secp256k1 `EcdsaSecp256k1VerificationKey2019`
|
|
21
22
|
* type with `publicKeyHex` for now (a `Multikey`/`publicKeyMultibase` form may
|
|
22
23
|
* be added later — see the plan's open risks).
|
package/dist/cjs/identityMove.js
CHANGED
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.identityMoveStateSchema = exports.identityMoveReceiptRequestSchema = exports.identityMoveSealRequestSchema = exports.identityMoveJoinRequestSchema = exports.identityMoveCreateResponseSchema = exports.identityMoveCreateRequestSchema = exports.IDENTITY_MOVE_QR_PREFIX = exports.IDENTITY_MOVE_STATUSES = exports.identityMoveEphemeralKeySchema = exports.identityMoveIdSchema = exports.IDENTITY_MOVE_TTL_MS = void 0;
|
|
3
|
+
exports.identityMoveStateSchema = exports.identityMoveReceiptRequestSchema = exports.identityMoveSealRequestSchema = exports.identityMoveRevealRequestSchema = exports.identityMoveJoinRequestSchema = exports.identityMoveCreateResponseSchema = exports.identityMoveCreateRequestSchema = exports.IDENTITY_MOVE_QR_PREFIX = exports.IDENTITY_MOVE_STATUSES = exports.identityMoveEphemeralKeySchema = exports.identityMoveIdSchema = exports.IDENTITY_MOVE_TTL_MS = void 0;
|
|
4
|
+
exports.buildMoveCommitmentInput = buildMoveCommitmentInput;
|
|
5
|
+
exports.buildMoveSasInput = buildMoveSasInput;
|
|
6
|
+
exports.buildMoveCiphertextDigestInput = buildMoveCiphertextDigestInput;
|
|
7
|
+
exports.buildMoveSealPayload = buildMoveSealPayload;
|
|
8
|
+
exports.buildMoveReceiptMessage = buildMoveReceiptMessage;
|
|
4
9
|
/**
|
|
5
|
-
* Identity move contract —
|
|
10
|
+
* Identity move contract — give a web root to Commons (add it, or keep it only in
|
|
11
|
+
* Commons; ADR 0024 D6).
|
|
6
12
|
*
|
|
7
|
-
*
|
|
13
|
+
* The web (holding the root) shows a QR carrying only a move id; Commons scans.
|
|
14
|
+
* The relay in between must not be able to take the root, so:
|
|
8
15
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* keys
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* the identity key over `{ action:'identity_move_received', moveId, timestamp }`
|
|
25
|
-
* (`POST /identity/move/:moveId/receipt`). The server checks it against the
|
|
26
|
-
* account's key; the WEB verifies it again locally before destroying its copy,
|
|
27
|
-
* so not even the server can fake a completed move.
|
|
16
|
+
* 1. `POST /identity/move { initiatorCommitment }` — the web publishes only
|
|
17
|
+
* `H(initiatorKey, nonce)`, never the key.
|
|
18
|
+
* 2. `POST /identity/move/:moveId/join { responderEphemeralPublicKey }` —
|
|
19
|
+
* Commons, having read the commitment first, joins with its own key.
|
|
20
|
+
* 3. `POST /identity/move/:moveId/reveal` — only then does the web reveal its
|
|
21
|
+
* key and nonce; Commons checks them against the commitment it read.
|
|
22
|
+
* 4. Both screens show a 6-digit code over `(moveId, both keys, commitment)`;
|
|
23
|
+
* the person confirms on the web that they match.
|
|
24
|
+
* 5. `POST /identity/move/:moveId/seal` — the web seals the phrase entropy under
|
|
25
|
+
* `HKDF(ECDH(both keys), moveId)`, authorized by a one-use root proof over the
|
|
26
|
+
* exact sealed bytes.
|
|
27
|
+
* 6. `POST /identity/move/:moveId/receipt` — Commons stores the root, reads it
|
|
28
|
+
* back from its keychain, and signs a receipt over the move, the root, both
|
|
29
|
+
* keys and the digest of the ciphertext it opened. The server verifies it; the
|
|
30
|
+
* web verifies it again from what IT sealed before removing anything.
|
|
28
31
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* Why the commitment: without it the relay sees both keys before committing to
|
|
33
|
+
* anything and can grind a substituted key until the two codes agree (~10⁶
|
|
34
|
+
* tries, seconds). With it, the relay must fix the key it shows Commons before
|
|
35
|
+
* learning Commons' key, and the key it shows the web before the web reveals.
|
|
36
|
+
*
|
|
37
|
+
* The server holds a commitment, two ephemeral public keys, an opaque ciphertext
|
|
38
|
+
* and a receipt — nothing that decrypts the ciphertext.
|
|
31
39
|
*
|
|
32
40
|
* Platform-agnostic — zod only, ESM-safe (no `require()`).
|
|
33
41
|
*/
|
|
34
42
|
const zod_1 = require("zod");
|
|
43
|
+
const identityProof_1 = require("./identityProof");
|
|
35
44
|
/** A move lives this long: one interactive handoff. */
|
|
36
45
|
exports.IDENTITY_MOVE_TTL_MS = 5 * 60 * 1000;
|
|
37
46
|
/** 128-bit move id, lowercase hex. */
|
|
@@ -47,9 +56,13 @@ exports.identityMoveEphemeralKeySchema = zod_1.z
|
|
|
47
56
|
exports.IDENTITY_MOVE_STATUSES = ['pending', 'joined', 'sealed', 'completed', 'cancelled', 'expired'];
|
|
48
57
|
/** The QR payload Commons scans. Carries the move id only. */
|
|
49
58
|
exports.IDENTITY_MOVE_QR_PREFIX = 'oxycommons://move?id=';
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
const hex64 = (label) => zod_1.z.string().trim().regex(/^[0-9a-f]{64}$/, `${label} must be 64 lowercase hex characters`);
|
|
60
|
+
exports.identityMoveCreateRequestSchema = zod_1.z
|
|
61
|
+
.object({
|
|
62
|
+
/** `H(initiator ephemeral key, nonce)` — the key itself is revealed only after Commons joins. */
|
|
63
|
+
initiatorCommitment: hex64('initiatorCommitment'),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
53
66
|
exports.identityMoveCreateResponseSchema = zod_1.z.object({
|
|
54
67
|
moveId: exports.identityMoveIdSchema,
|
|
55
68
|
expiresAt: zod_1.z.string().datetime(),
|
|
@@ -57,29 +70,87 @@ exports.identityMoveCreateResponseSchema = zod_1.z.object({
|
|
|
57
70
|
exports.identityMoveJoinRequestSchema = zod_1.z.object({
|
|
58
71
|
responderEphemeralPublicKey: exports.identityMoveEphemeralKeySchema,
|
|
59
72
|
});
|
|
60
|
-
|
|
73
|
+
/** After Commons joined: the committed key and its nonce. */
|
|
74
|
+
exports.identityMoveRevealRequestSchema = zod_1.z
|
|
75
|
+
.object({
|
|
76
|
+
initiatorEphemeralPublicKey: exports.identityMoveEphemeralKeySchema,
|
|
77
|
+
commitmentNonce: hex64('commitmentNonce'),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
exports.identityMoveSealRequestSchema = zod_1.z
|
|
81
|
+
.object({
|
|
61
82
|
/** 24-byte XChaCha20-Poly1305 nonce, hex. */
|
|
62
83
|
nonce: zod_1.z.string().trim().regex(/^[0-9a-f]{48}$/, 'nonce must be 48 lowercase hex characters'),
|
|
63
|
-
/** The 16-byte entropy, tag appended
|
|
64
|
-
ciphertext: zod_1.z
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
/** The 16–32-byte phrase entropy (12–24 words), tag appended, hex. */
|
|
85
|
+
ciphertext: zod_1.z
|
|
86
|
+
.string()
|
|
87
|
+
.trim()
|
|
88
|
+
.regex(/^(?:[0-9a-f]{64}|[0-9a-f]{72}|[0-9a-f]{80}|[0-9a-f]{88}|[0-9a-f]{96})$/, 'ciphertext has an unsupported length'),
|
|
89
|
+
/**
|
|
90
|
+
* Root proof (`identity_move_seal`), payload `{ moveId, nonce, ciphertext }`,
|
|
91
|
+
* over a one-use challenge from `POST /identity/proof-challenge`.
|
|
92
|
+
*/
|
|
93
|
+
proof: identityProof_1.identityProofSchema,
|
|
94
|
+
})
|
|
95
|
+
.strict();
|
|
96
|
+
exports.identityMoveReceiptRequestSchema = zod_1.z
|
|
97
|
+
.object({
|
|
98
|
+
/** Root signature over `buildMoveReceiptMessage(...)`. */
|
|
71
99
|
signature: zod_1.z.string().trim().min(1).max(512),
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
})
|
|
101
|
+
.strict();
|
|
74
102
|
exports.identityMoveStateSchema = zod_1.z.object({
|
|
75
103
|
moveId: exports.identityMoveIdSchema,
|
|
76
104
|
status: zod_1.z.enum(exports.IDENTITY_MOVE_STATUSES),
|
|
105
|
+
initiatorCommitment: zod_1.z.string(),
|
|
106
|
+
initiatorCommitmentNonce: zod_1.z.string().nullable(),
|
|
77
107
|
publicKey: zod_1.z.string().regex(/^04[0-9a-f]{128}$/),
|
|
78
|
-
initiatorEphemeralPublicKey: exports.identityMoveEphemeralKeySchema,
|
|
108
|
+
initiatorEphemeralPublicKey: exports.identityMoveEphemeralKeySchema.nullable(),
|
|
79
109
|
responderEphemeralPublicKey: exports.identityMoveEphemeralKeySchema.nullable(),
|
|
80
110
|
nonce: zod_1.z.string().nullable(),
|
|
81
111
|
ciphertext: zod_1.z.string().nullable(),
|
|
82
112
|
receiptSignature: zod_1.z.string().nullable(),
|
|
83
|
-
receiptTimestamp: zod_1.z.number().int().nullable(),
|
|
84
113
|
expiresAt: zod_1.z.string().datetime(),
|
|
85
114
|
});
|
|
115
|
+
/**
|
|
116
|
+
* The initiator's commitment input, `canonicalJson({ v, purpose, key, nonce })`,
|
|
117
|
+
* which the caller hashes with SHA-256.
|
|
118
|
+
*/
|
|
119
|
+
function buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce) {
|
|
120
|
+
return (0, identityProof_1.canonicalJson)({
|
|
121
|
+
v: 2,
|
|
122
|
+
purpose: 'oxy-identity-move-initiator-commitment',
|
|
123
|
+
initiatorEphemeralPublicKey: initiatorEphemeralPublicKey.toLowerCase(),
|
|
124
|
+
nonce: nonce.toLowerCase(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** The bytes both sides hash for the 6-digit code. */
|
|
128
|
+
function buildMoveSasInput(input) {
|
|
129
|
+
return (0, identityProof_1.canonicalJson)({
|
|
130
|
+
v: 'oxy-identity-transfer-sas-v2',
|
|
131
|
+
moveId: input.moveId.toLowerCase(),
|
|
132
|
+
initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
|
|
133
|
+
responder: input.responderEphemeralPublicKey.toLowerCase(),
|
|
134
|
+
commitment: input.initiatorCommitment.toLowerCase(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** The ciphertext digest input the receipt binds: `canonicalJson({ nonce, ciphertext })`. */
|
|
138
|
+
function buildMoveCiphertextDigestInput(sealed) {
|
|
139
|
+
return (0, identityProof_1.canonicalJson)({ nonce: sealed.nonce.toLowerCase(), ciphertext: sealed.ciphertext.toLowerCase() });
|
|
140
|
+
}
|
|
141
|
+
/** The payload a seal proof digests. */
|
|
142
|
+
function buildMoveSealPayload(moveId, sealed) {
|
|
143
|
+
return { moveId: moveId.toLowerCase(), nonce: sealed.nonce.toLowerCase(), ciphertext: sealed.ciphertext.toLowerCase() };
|
|
144
|
+
}
|
|
145
|
+
/** The exact bytes a receipt signs. */
|
|
146
|
+
function buildMoveReceiptMessage(input) {
|
|
147
|
+
return (0, identityProof_1.canonicalJson)({
|
|
148
|
+
v: 2,
|
|
149
|
+
domain: 'oxy-identity-move-receipt',
|
|
150
|
+
moveId: input.moveId.toLowerCase(),
|
|
151
|
+
rootPublicKey: input.rootPublicKey.toLowerCase(),
|
|
152
|
+
initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
|
|
153
|
+
responder: input.responderEphemeralPublicKey.toLowerCase(),
|
|
154
|
+
ciphertextDigest: input.ciphertextDigest.toLowerCase(),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.identityRootStatusSchema = exports.IDENTITY_ERROR_CODES = exports.identityProofChallengeResponseSchema = exports.identityProofChallengeRequestSchema = exports.identityProofSchema = exports.IDENTITY_PROOF_ACTION_VALUES = exports.IDENTITY_PROOF_ACTIONS = exports.IDENTITY_PROOF_CHALLENGE_TTL_MS = exports.IDENTITY_PROOF_AUDIENCE = exports.IDENTITY_PROOF_DOMAIN = exports.IDENTITY_PROOF_VERSION = void 0;
|
|
4
|
+
exports.canonicalJson = canonicalJson;
|
|
5
|
+
exports.buildIdentityProofMessage = buildIdentityProofMessage;
|
|
6
|
+
/**
|
|
7
|
+
* Identity proof contract — the ONE signed format for operations on a personal root.
|
|
8
|
+
*
|
|
9
|
+
* ADR 0024 D7. A proof is a signature by the root key over the canonical bytes
|
|
10
|
+
* of {@link IdentityProofClaims}. Every field the verifier cares about is IN the
|
|
11
|
+
* signed bytes, so a signature cannot be moved to another operation, account,
|
|
12
|
+
* root, payload, revision or audience, and the one-use `challenge` means it
|
|
13
|
+
* cannot be replayed either (a timestamp window is not replay protection).
|
|
14
|
+
*
|
|
15
|
+
* Both sides build the bytes with {@link buildIdentityProofMessage} and hash
|
|
16
|
+
* payloads with {@link canonicalJson}; neither writes its own JSON template, so
|
|
17
|
+
* the client and the verifier cannot drift apart.
|
|
18
|
+
*
|
|
19
|
+
* Platform-agnostic — zod only, ESM-safe (no `require()`), no hashing here (the
|
|
20
|
+
* caller hashes `canonicalJson(payload)` with SHA-256 using its platform's
|
|
21
|
+
* primitive and passes the hex digest in).
|
|
22
|
+
*/
|
|
23
|
+
const zod_1 = require("zod");
|
|
24
|
+
exports.IDENTITY_PROOF_VERSION = 2;
|
|
25
|
+
exports.IDENTITY_PROOF_DOMAIN = 'oxy-identity-proof';
|
|
26
|
+
/** The audience every API-verified identity proof names. */
|
|
27
|
+
exports.IDENTITY_PROOF_AUDIENCE = 'oxy-api/identity';
|
|
28
|
+
/** How long a proof challenge lives: one interactive ceremony. */
|
|
29
|
+
exports.IDENTITY_PROOF_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
/**
|
|
31
|
+
* Everything a root proof may authorize. A challenge is minted for exactly one
|
|
32
|
+
* action and spent only by a proof for that action.
|
|
33
|
+
*/
|
|
34
|
+
exports.IDENTITY_PROOF_ACTIONS = {
|
|
35
|
+
/** A keyless account's FIRST root, stored with its web envelope. */
|
|
36
|
+
establish: 'web_envelope_establish',
|
|
37
|
+
/** Replace the web envelope (add or remove a wrap, re-seal). */
|
|
38
|
+
put: 'web_envelope_put',
|
|
39
|
+
/** Record that the recovery material is written down. */
|
|
40
|
+
phraseConfirmed: 'web_envelope_phrase_confirmed',
|
|
41
|
+
/** Record that the recovery material re-derived the root. */
|
|
42
|
+
recoveryVerified: 'web_envelope_recovery_verified',
|
|
43
|
+
/** Remove the web holder. */
|
|
44
|
+
delete: 'web_envelope_delete',
|
|
45
|
+
/** Link a keyless account's first root without a web envelope (`POST /auth/link`). */
|
|
46
|
+
link: 'link_identity',
|
|
47
|
+
/** Create a personal account together with its root (passkey sign-up). */
|
|
48
|
+
enroll: 'enroll_identity',
|
|
49
|
+
/** Prove the root to start signed-out recovery. */
|
|
50
|
+
recoverStart: 'recover_account_start',
|
|
51
|
+
/** Bind the new passkey and envelope when completing signed-out recovery. */
|
|
52
|
+
recoverComplete: 'recover_account_complete',
|
|
53
|
+
/** Seal the root for the Commons device that joined a move (payload: move id + sealed bytes). */
|
|
54
|
+
moveSeal: 'identity_move_seal',
|
|
55
|
+
};
|
|
56
|
+
exports.IDENTITY_PROOF_ACTION_VALUES = Object.values(exports.IDENTITY_PROOF_ACTIONS);
|
|
57
|
+
const HEX_DIGEST = /^[0-9a-f]{64}$/;
|
|
58
|
+
const ROOT_KEY = /^04[0-9a-f]{128}$/;
|
|
59
|
+
const CHALLENGE = /^[0-9a-f]{64}$/;
|
|
60
|
+
/**
|
|
61
|
+
* Canonical JSON: object keys sorted by UTF-16 code unit, no whitespace,
|
|
62
|
+
* `undefined` members omitted, arrays in order. Numbers must be finite. This is
|
|
63
|
+
* the ONLY serializer for anything digested into a proof.
|
|
64
|
+
*/
|
|
65
|
+
function canonicalJson(value) {
|
|
66
|
+
if (value === null)
|
|
67
|
+
return 'null';
|
|
68
|
+
switch (typeof value) {
|
|
69
|
+
case 'string':
|
|
70
|
+
case 'boolean':
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
case 'number':
|
|
73
|
+
if (!Number.isFinite(value))
|
|
74
|
+
throw new Error('canonicalJson: non-finite number');
|
|
75
|
+
return JSON.stringify(value);
|
|
76
|
+
case 'object': {
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
return `[${value.map((entry) => (entry === undefined ? 'null' : canonicalJson(entry))).join(',')}]`;
|
|
79
|
+
}
|
|
80
|
+
const record = value;
|
|
81
|
+
const keys = Object.keys(record)
|
|
82
|
+
.filter((key) => record[key] !== undefined)
|
|
83
|
+
.sort();
|
|
84
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(`canonicalJson: unsupported ${typeof value}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The exact bytes a root signs. Throws on a malformed claim rather than signing
|
|
92
|
+
* (or verifying) something ambiguous.
|
|
93
|
+
*/
|
|
94
|
+
function buildIdentityProofMessage(claims) {
|
|
95
|
+
if (!exports.IDENTITY_PROOF_ACTION_VALUES.includes(claims.action))
|
|
96
|
+
throw new Error('identity proof: unknown action');
|
|
97
|
+
if (!claims.subject || !claims.actor)
|
|
98
|
+
throw new Error('identity proof: subject and actor are required');
|
|
99
|
+
if (!ROOT_KEY.test(claims.rootPublicKey))
|
|
100
|
+
throw new Error('identity proof: rootPublicKey must be canonical');
|
|
101
|
+
if (claims.payloadDigest !== null && !HEX_DIGEST.test(claims.payloadDigest)) {
|
|
102
|
+
throw new Error('identity proof: payloadDigest must be a lowercase SHA-256 hex digest');
|
|
103
|
+
}
|
|
104
|
+
if (claims.expectedRevision !== null && (!Number.isSafeInteger(claims.expectedRevision) || claims.expectedRevision < 0)) {
|
|
105
|
+
throw new Error('identity proof: expectedRevision must be a non-negative integer');
|
|
106
|
+
}
|
|
107
|
+
if (!CHALLENGE.test(claims.challenge))
|
|
108
|
+
throw new Error('identity proof: challenge must be 64 lowercase hex characters');
|
|
109
|
+
if (!Number.isSafeInteger(claims.expiresAt) || claims.expiresAt <= 0)
|
|
110
|
+
throw new Error('identity proof: expiresAt must be unix milliseconds');
|
|
111
|
+
return canonicalJson({
|
|
112
|
+
v: exports.IDENTITY_PROOF_VERSION,
|
|
113
|
+
domain: exports.IDENTITY_PROOF_DOMAIN,
|
|
114
|
+
action: claims.action,
|
|
115
|
+
subject: claims.subject,
|
|
116
|
+
actor: claims.actor,
|
|
117
|
+
rootPublicKey: claims.rootPublicKey,
|
|
118
|
+
payloadDigest: claims.payloadDigest,
|
|
119
|
+
expectedRevision: claims.expectedRevision,
|
|
120
|
+
audience: claims.audience,
|
|
121
|
+
challenge: claims.challenge,
|
|
122
|
+
expiresAt: claims.expiresAt,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** The proof as it travels: the signature plus the two claims the verifier cannot derive. */
|
|
126
|
+
exports.identityProofSchema = zod_1.z.object({
|
|
127
|
+
v: zod_1.z.literal(exports.IDENTITY_PROOF_VERSION),
|
|
128
|
+
challenge: zod_1.z.string().trim().regex(CHALLENGE, 'challenge must be 64 lowercase hex characters'),
|
|
129
|
+
expiresAt: zod_1.z.number().int().positive(),
|
|
130
|
+
signature: zod_1.z.string().trim().min(1).max(512),
|
|
131
|
+
});
|
|
132
|
+
/** `POST /identity/proof-challenge` */
|
|
133
|
+
exports.identityProofChallengeRequestSchema = zod_1.z.object({
|
|
134
|
+
action: zod_1.z.enum(exports.IDENTITY_PROOF_ACTION_VALUES),
|
|
135
|
+
});
|
|
136
|
+
exports.identityProofChallengeResponseSchema = zod_1.z.object({
|
|
137
|
+
challenge: zod_1.z.string().regex(CHALLENGE),
|
|
138
|
+
expiresAt: zod_1.z.number().int().positive(),
|
|
139
|
+
audience: zod_1.z.string().min(1),
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* Stable error codes the root routes answer with (`error.code` in the API error
|
|
143
|
+
* body). Clients map these through their localization, never the English message.
|
|
144
|
+
*/
|
|
145
|
+
exports.IDENTITY_ERROR_CODES = {
|
|
146
|
+
proofInvalid: 'IDENTITY_PROOF_INVALID',
|
|
147
|
+
revisionConflict: 'IDENTITY_ENVELOPE_REVISION_CONFLICT',
|
|
148
|
+
rootAlreadyLinked: 'IDENTITY_ROOT_ALREADY_LINKED',
|
|
149
|
+
rootLinkedElsewhere: 'IDENTITY_ROOT_LINKED_ELSEWHERE',
|
|
150
|
+
noRoot: 'IDENTITY_NO_ROOT',
|
|
151
|
+
freshFactorRequired: 'IDENTITY_FRESH_FACTOR_REQUIRED',
|
|
152
|
+
lastWebHolder: 'IDENTITY_LAST_WEB_HOLDER',
|
|
153
|
+
enrollmentRequired: 'IDENTITY_ENROLLMENT_REQUIRED',
|
|
154
|
+
enrollmentInvalid: 'IDENTITY_ENROLLMENT_INVALID',
|
|
155
|
+
notPersonal: 'IDENTITY_NOT_PERSONAL_ACCOUNT',
|
|
156
|
+
recoveryFailed: 'IDENTITY_RECOVERY_FAILED',
|
|
157
|
+
};
|
|
158
|
+
exports.identityRootStatusSchema = zod_1.z.object({
|
|
159
|
+
rootLinked: zod_1.z.boolean(),
|
|
160
|
+
webHolder: zod_1.z.object({ passkeys: zod_1.z.number().int().nonnegative(), verifiedPasskeys: zod_1.z.number().int().nonnegative() }).nullable(),
|
|
161
|
+
hasPhrase: zod_1.z.boolean().nullable(),
|
|
162
|
+
phraseConfirmedAt: zod_1.z.string().datetime().nullable(),
|
|
163
|
+
recoveryVerifiedAt: zod_1.z.string().datetime().nullable(),
|
|
164
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.identityRecoveryCompleteRequestSchema = exports.identityRecoveryStartRequestSchema = exports.identityRecoveryChallengeResponseSchema = exports.IDENTITY_RECOVERY_TTL_MS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Signed-out recovery contract (ADR 0024 D5) — get an EXISTING account back from
|
|
6
|
+
* its root alone, with no passkey, session or email.
|
|
7
|
+
*
|
|
8
|
+
* 1. `POST /identity/recovery/challenge` → a one-use challenge. It says nothing
|
|
9
|
+
* about any account.
|
|
10
|
+
* 2. The holder derives the root locally from recovery material (12/24 words or a
|
|
11
|
+
* raw key) and signs `recover_account_start` over that challenge:
|
|
12
|
+
* `POST /identity/recovery/start { publicKey, proof }`. Only a valid proof
|
|
13
|
+
* learns which account the root belongs to; the response carries passkey
|
|
14
|
+
* registration options for THAT account and a short-lived ticket.
|
|
15
|
+
* 3. The holder creates the passkey, seals the root under it (PRF), and signs
|
|
16
|
+
* `recover_account_complete` over the registration challenge and the envelope
|
|
17
|
+
* digest: `POST /identity/recovery/complete`. The passkey, the envelope and a
|
|
18
|
+
* session are created in one transaction.
|
|
19
|
+
*
|
|
20
|
+
* The recovery material and the root never leave the holder. Platform-agnostic —
|
|
21
|
+
* zod only, ESM-safe.
|
|
22
|
+
*/
|
|
23
|
+
const zod_1 = require("zod");
|
|
24
|
+
const identityProof_1 = require("./identityProof");
|
|
25
|
+
const webIdentityCarrier_1 = require("./webIdentityCarrier");
|
|
26
|
+
/** A recovery attempt lives this long between steps. */
|
|
27
|
+
exports.IDENTITY_RECOVERY_TTL_MS = 5 * 60 * 1000;
|
|
28
|
+
exports.identityRecoveryChallengeResponseSchema = zod_1.z.object({
|
|
29
|
+
challenge: zod_1.z.string().regex(/^[0-9a-f]{64}$/),
|
|
30
|
+
expiresAt: zod_1.z.number().int().positive(),
|
|
31
|
+
});
|
|
32
|
+
exports.identityRecoveryStartRequestSchema = zod_1.z
|
|
33
|
+
.object({
|
|
34
|
+
publicKey: webIdentityCarrier_1.webIdentityPublicKeySchema,
|
|
35
|
+
/** `recover_account_start`, subject `root:<publicKey>`, actor `anonymous`. */
|
|
36
|
+
proof: identityProof_1.identityProofSchema,
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
39
|
+
exports.identityRecoveryCompleteRequestSchema = zod_1.z
|
|
40
|
+
.object({
|
|
41
|
+
ticket: zod_1.z.string().regex(/^[0-9a-f]{64}$/),
|
|
42
|
+
/** The browser `RegistrationResponseJSON`, verified by the API's WebAuthn library. */
|
|
43
|
+
response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
44
|
+
/** The root sealed under the new passkey — exactly one wrap, for that credential. */
|
|
45
|
+
envelope: webIdentityCarrier_1.webIdentityEnvelopeSchema,
|
|
46
|
+
/** `recover_account_complete`, subject = account id, actor `credential:<id>`, challenge = registration challenge (hex). */
|
|
47
|
+
proof: identityProof_1.identityProofSchema,
|
|
48
|
+
deviceName: zod_1.z.string().trim().max(100).optional(),
|
|
49
|
+
deviceFingerprint: zod_1.z.string().trim().max(512).optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|