@plane31/blind-relay 0.1.1 → 0.1.2
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/dist/crypto/sodium.d.ts +5 -3
- package/dist/crypto/sodium.d.ts.map +1 -1
- package/dist/crypto/sodium.js +13 -1
- package/dist/crypto/sodium.js.map +1 -1
- package/package.json +3 -2
- package/src/adapters/index.ts +102 -0
- package/src/audience.ts +123 -0
- package/src/bytes.ts +209 -0
- package/src/circle.ts +127 -0
- package/src/client.ts +1442 -0
- package/src/codec.ts +642 -0
- package/src/crypto/backend.ts +48 -0
- package/src/crypto/noble.ts +104 -0
- package/src/crypto/node-module.d.ts +11 -0
- package/src/crypto/sodium.ts +53 -0
- package/src/crypto.ts +266 -0
- package/src/epoch.ts +201 -0
- package/src/errors.ts +78 -0
- package/src/historyIndex.ts +61 -0
- package/src/identity.ts +158 -0
- package/src/inbox.ts +560 -0
- package/src/index.ts +59 -0
- package/src/outbox.ts +385 -0
- package/src/policy.ts +88 -0
- package/src/record.ts +106 -0
- package/src/reset.ts +176 -0
- package/src/rows.ts +116 -0
- package/src/slots.ts +55 -0
- package/src/store.ts +557 -0
- package/src/testing/clock.ts +29 -0
- package/src/testing/fakeHost.ts +482 -0
- package/src/testing/faults.ts +321 -0
- package/src/testing/fixtures.ts +35 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/providers.ts +39 -0
- package/src/testing/sweep.ts +407 -0
- package/src/transactions.ts +231 -0
- package/src/transport.ts +106 -0
- package/src/vault.ts +126 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport the host implements (protocol section 13): a directory and a relay. The SDK
|
|
3
|
+
* passes opaque bytes through unchanged; no plaintext payload ever enters this interface.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type MutationStatus = "COMMITTED" | "ALREADY_COMMITTED" | "CONFLICT" | "IGNORED_OLDER" | "RETRYABLE" | "UNAUTHORIZED" | "PERMANENT_FAILURE";
|
|
7
|
+
|
|
8
|
+
export interface MutationOutcome {
|
|
9
|
+
readonly status: MutationStatus;
|
|
10
|
+
/** On CONFLICT: the bytes the host currently holds, when it can return them. */
|
|
11
|
+
readonly current?: Uint8Array | undefined;
|
|
12
|
+
readonly currentRevision?: bigint | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolvedAccount {
|
|
16
|
+
readonly accountName: string;
|
|
17
|
+
readonly accountKey: Uint8Array;
|
|
18
|
+
/** The current certificate wire. */
|
|
19
|
+
readonly certificate: Uint8Array;
|
|
20
|
+
/** When the account's key changed through a reset: the receipt that proves it. */
|
|
21
|
+
readonly resetReceipt?: Uint8Array | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface JoinRequest {
|
|
25
|
+
readonly circleId: Uint8Array;
|
|
26
|
+
readonly accountName: string;
|
|
27
|
+
readonly action: "join" | "leave";
|
|
28
|
+
readonly requestedAt: bigint;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Directory {
|
|
32
|
+
resolveAccounts(names: readonly string[]): Promise<readonly ResolvedAccount[]>;
|
|
33
|
+
/** Snapshots name identities; the directory maps them back to accounts (host-trusted, like first contact). */
|
|
34
|
+
resolveIdentities(identityIds: readonly Uint8Array[]): Promise<readonly ResolvedAccount[]>;
|
|
35
|
+
getCertificate(hash: Uint8Array): Promise<Uint8Array | undefined>;
|
|
36
|
+
activateDevice(input: {
|
|
37
|
+
accountName: string;
|
|
38
|
+
expectedCertificateHash: Uint8Array;
|
|
39
|
+
expectedVaultRevision: bigint;
|
|
40
|
+
expectedVaultHash: Uint8Array;
|
|
41
|
+
newCertificate: Uint8Array;
|
|
42
|
+
newVault: Uint8Array;
|
|
43
|
+
}): Promise<MutationOutcome>;
|
|
44
|
+
registerAccount(input: { certificate: Uint8Array; vault: Uint8Array }): Promise<MutationOutcome>;
|
|
45
|
+
getVault(accountName: string): Promise<Uint8Array | undefined>;
|
|
46
|
+
publishVault(input: { expectedRevision: bigint; expectedHash: Uint8Array | undefined; newVault: Uint8Array }): Promise<MutationOutcome>;
|
|
47
|
+
getCircle(circleId: Uint8Array): Promise<Uint8Array | undefined>;
|
|
48
|
+
publishOwnedCircle(input: { circleId: Uint8Array; expectedRevision: bigint; expectedHash: Uint8Array | undefined; newSnapshot: Uint8Array }): Promise<MutationOutcome>;
|
|
49
|
+
requestJoin(circleId: Uint8Array): Promise<MutationOutcome>;
|
|
50
|
+
requestLeave(circleId: Uint8Array): Promise<MutationOutcome>;
|
|
51
|
+
readRequests(circleId: Uint8Array): Promise<readonly JoinRequest[]>;
|
|
52
|
+
getResetKeyList(): Promise<Uint8Array | undefined>;
|
|
53
|
+
getResetReceipt(resetId: Uint8Array): Promise<Uint8Array | undefined>;
|
|
54
|
+
resetAccount(input: {
|
|
55
|
+
accountName: string;
|
|
56
|
+
expectedOldIdentityId: Uint8Array;
|
|
57
|
+
newCertificate: Uint8Array;
|
|
58
|
+
newVault: Uint8Array;
|
|
59
|
+
resetId: Uint8Array;
|
|
60
|
+
reauthProof: Uint8Array;
|
|
61
|
+
}): Promise<MutationOutcome & { receipt?: Uint8Array | undefined }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface EpochBundle {
|
|
65
|
+
readonly manifest: Uint8Array;
|
|
66
|
+
/** The caller's own wrap. */
|
|
67
|
+
readonly wrap: Uint8Array | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type AppendStatus = "ACCEPTED" | "ALREADY_ACCEPTED" | "EQUIVOCATION" | "STALE_APPEND" | "RETRYABLE" | "UNAUTHORIZED" | "SUPERSEDED_DEVICE" | "EPOCH_FENCED";
|
|
71
|
+
|
|
72
|
+
export interface AppendOutcome {
|
|
73
|
+
readonly status: AppendStatus;
|
|
74
|
+
readonly deliverySeq?: bigint;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ReadRecordsResult {
|
|
78
|
+
readonly records: readonly { readonly deliverySeq: bigint; readonly wire: Uint8Array }[];
|
|
79
|
+
readonly nextSeq: bigint;
|
|
80
|
+
readonly tail: bigint;
|
|
81
|
+
readonly expiredThrough: bigint;
|
|
82
|
+
readonly committedCursor: bigint;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Relay {
|
|
86
|
+
getCurrentEpoch(streamId: Uint8Array): Promise<{ epoch: number; manifestHash: Uint8Array } | undefined>;
|
|
87
|
+
getEpoch(streamId: Uint8Array, epoch: number): Promise<EpochBundle | undefined>;
|
|
88
|
+
publishEpoch(input: {
|
|
89
|
+
streamId: Uint8Array;
|
|
90
|
+
expectedEpoch: number;
|
|
91
|
+
expectedHash: Uint8Array | undefined;
|
|
92
|
+
manifest: Uint8Array;
|
|
93
|
+
wraps: ReadonlyMap<string, Uint8Array>;
|
|
94
|
+
}): Promise<MutationOutcome & { currentEpoch?: number | undefined; currentManifest?: Uint8Array | undefined }>;
|
|
95
|
+
/** MUST be callable from inside T6h without awaiting: it queues the bytes synchronously and returns a promise. */
|
|
96
|
+
appendRecord(recordId: Uint8Array, wire: Uint8Array): Promise<AppendOutcome>;
|
|
97
|
+
readRecords(streamId: Uint8Array, afterSeq: bigint, limit: number): Promise<ReadRecordsResult>;
|
|
98
|
+
ackThrough(streamId: Uint8Array, cursor: bigint): Promise<{ status: "COMMITTED" | "CURSOR_OUT_OF_RANGE" | "RETRYABLE" | "UNAUTHORIZED"; committedCursor?: bigint }>;
|
|
99
|
+
/** The streams this reader is subscribed to (the host creates subscriptions). */
|
|
100
|
+
listSubscriptions(): Promise<readonly { streamId: Uint8Array; authorIdentityId: Uint8Array }[]>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Transport {
|
|
104
|
+
readonly directory: Directory;
|
|
105
|
+
readonly relay: Relay;
|
|
106
|
+
}
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account vault and the recovery order (protocol section 5).
|
|
3
|
+
*
|
|
4
|
+
* The vault carries the account seed, the last device checkpoint and the circle pins with their
|
|
5
|
+
* sharing preference. It never carries a key that decrypts a record. Recovery tries the platform
|
|
6
|
+
* provider, then a saved code, and otherwise reports `ACCOUNT_RECOVERY_REQUIRED`; it never
|
|
7
|
+
* manufactures identity keys.
|
|
8
|
+
*/
|
|
9
|
+
import { compareBytes, equalBytes } from "./bytes.js";
|
|
10
|
+
import {
|
|
11
|
+
decodeRecoveryCode,
|
|
12
|
+
encodeRecoveryCode,
|
|
13
|
+
openVault,
|
|
14
|
+
sealVault,
|
|
15
|
+
type OpenedVault,
|
|
16
|
+
type VaultContents,
|
|
17
|
+
type VaultEntry,
|
|
18
|
+
type VaultHeader
|
|
19
|
+
} from "./codec.js";
|
|
20
|
+
import { KEY_BYTES, NONCE_BYTES, bodyHash, randomBytes } from "./crypto.js";
|
|
21
|
+
import { KavachError, isKavachError } from "./errors.js";
|
|
22
|
+
|
|
23
|
+
export { encodeRecoveryCode, decodeRecoveryCode, openVault, sealVault };
|
|
24
|
+
export type { OpenedVault, VaultContents, VaultEntry, VaultHeader };
|
|
25
|
+
|
|
26
|
+
export type ProviderStatus = "available" | "unavailable" | "locked" | "unsupported" | "error";
|
|
27
|
+
export type BackupState = "unknown" | "pending" | "unavailable";
|
|
28
|
+
|
|
29
|
+
export interface ProviderLoadResult {
|
|
30
|
+
readonly status: ProviderStatus;
|
|
31
|
+
readonly reason?: string;
|
|
32
|
+
readonly recoveryKey?: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProviderSaveResult {
|
|
36
|
+
readonly status: ProviderStatus;
|
|
37
|
+
readonly reason?: string;
|
|
38
|
+
/** A successful local write is never reported as a completed cloud backup. */
|
|
39
|
+
readonly backupState: BackupState;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProviderStatusResult {
|
|
43
|
+
readonly status: ProviderStatus;
|
|
44
|
+
readonly backupState: BackupState;
|
|
45
|
+
readonly reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The platform recovery provider the host supplies (synced Keychain, Block Store, ...). */
|
|
49
|
+
export interface RecoveryProvider {
|
|
50
|
+
load(accountName: string, identityId: Uint8Array): Promise<ProviderLoadResult>;
|
|
51
|
+
save(accountName: string, identityId: Uint8Array, recoveryKey: Uint8Array): Promise<ProviderSaveResult>;
|
|
52
|
+
status(): Promise<ProviderStatusResult>;
|
|
53
|
+
/** Logout or retirement: forget local access without deleting the shared item. */
|
|
54
|
+
eraseLocalAccess(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function newRecoveryKey(): Uint8Array {
|
|
58
|
+
return randomBytes(KEY_BYTES);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Seal a vault at a revision with a fresh nonce. Returns the exact bytes and their hash. */
|
|
62
|
+
export function buildVault(header: VaultHeader, contents: VaultContents, recoveryKey: Uint8Array): { wire: Uint8Array; hash: Uint8Array } {
|
|
63
|
+
const wire = sealVault(header, contents, recoveryKey, randomBytes(NONCE_BYTES));
|
|
64
|
+
return { wire, hash: bodyHash(wire) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Pure edits to the entry list, keeping it sorted and unique by circle id. */
|
|
68
|
+
export function upsertEntry(entries: readonly VaultEntry[], entry: VaultEntry): VaultEntry[] {
|
|
69
|
+
const rest = entries.filter((e) => !equalBytes(e.circleId, entry.circleId));
|
|
70
|
+
return [...rest, entry].sort((a, b) => compareBytes(a.circleId, b.circleId));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function removeEntry(entries: readonly VaultEntry[], circleId: Uint8Array): VaultEntry[] {
|
|
74
|
+
return entries.filter((e) => !equalBytes(e.circleId, circleId));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type RecoverySource = "provider" | "code";
|
|
78
|
+
|
|
79
|
+
export interface RecoveryOutcome {
|
|
80
|
+
readonly vault: OpenedVault;
|
|
81
|
+
readonly recoveryKey: Uint8Array;
|
|
82
|
+
readonly source: RecoverySource;
|
|
83
|
+
readonly providerStatus: ProviderStatus;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The recovery order. The provider is asked first; a saved code second; when neither opens the
|
|
88
|
+
* vault the result is `ACCOUNT_RECOVERY_REQUIRED` and the caller preserves what it cannot use.
|
|
89
|
+
* A wrong key is an authentication failure and never a reason to reset.
|
|
90
|
+
*/
|
|
91
|
+
export async function recoverAccount(input: {
|
|
92
|
+
readonly accountName: string;
|
|
93
|
+
readonly identityId: Uint8Array;
|
|
94
|
+
readonly accountKey: Uint8Array;
|
|
95
|
+
readonly vaultWire: Uint8Array;
|
|
96
|
+
readonly provider: RecoveryProvider;
|
|
97
|
+
readonly recoveryCode?: string;
|
|
98
|
+
}): Promise<RecoveryOutcome> {
|
|
99
|
+
const stage = "recoverAccount";
|
|
100
|
+
const tryOpen = (key: Uint8Array): OpenedVault | undefined => {
|
|
101
|
+
try {
|
|
102
|
+
return openVault(input.vaultWire, key, input.accountName, input.accountKey);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (isKavachError(e, "DECRYPT_FAILED")) return undefined;
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
let providerStatus: ProviderStatus = "error";
|
|
109
|
+
try {
|
|
110
|
+
const loaded = await input.provider.load(input.accountName, input.identityId);
|
|
111
|
+
providerStatus = loaded.status;
|
|
112
|
+
if (loaded.status === "available" && loaded.recoveryKey) {
|
|
113
|
+
const vault = tryOpen(loaded.recoveryKey);
|
|
114
|
+
if (vault) return { vault, recoveryKey: loaded.recoveryKey, source: "provider", providerStatus };
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
providerStatus = "error";
|
|
118
|
+
}
|
|
119
|
+
if (input.recoveryCode !== undefined) {
|
|
120
|
+
const key = decodeRecoveryCode(input.recoveryCode);
|
|
121
|
+
const vault = tryOpen(key);
|
|
122
|
+
if (vault) return { vault, recoveryKey: key, source: "code", providerStatus };
|
|
123
|
+
throw new KavachError("DECRYPT_FAILED", stage, "recovery code does not open the vault");
|
|
124
|
+
}
|
|
125
|
+
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, `provider ${providerStatus}, no code`);
|
|
126
|
+
}
|