Merge: Phase 2 crypto primitives
Implements all the libsodium primitives quack needs to unwrap key material and decrypt files (KDF, secretbox, sealed box, secretstream pull). Built test-first per the development workflow; 32 tests pass.
This commit is contained in:
16
README.md
16
README.md
@@ -592,15 +592,17 @@ Phase 1: scaffolding
|
|||||||
|
|
||||||
Phase 2: crypto primitives
|
Phase 2: crypto primitives
|
||||||
|
|
||||||
- [ ] Wrap libsodium init as an awaitable singleton
|
- [x] Wrap libsodium init as an awaitable singleton
|
||||||
- [ ] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
|
- [x] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
|
||||||
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`, 16
|
- [x] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`, 16
|
||||||
bytes)
|
bytes)
|
||||||
- [ ] `decryptBox(ciphertext, nonce, key)` for secretbox
|
- [x] `decryptBox(ciphertext, nonce, key)` for secretbox
|
||||||
- [ ] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
|
- [x] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
|
||||||
- [ ] `initStreamPull` and `pullStreamChunk` for chunked secretstream (4 MiB
|
- [x] `initStreamPull` and `pullStreamChunk` for chunked secretstream (4 MiB
|
||||||
plaintext chunks, 17-byte overhead)
|
plaintext chunks, 17-byte overhead)
|
||||||
- [ ] Round-trip tests against vectors generated by libsodium directly
|
- [x] Round-trip tests against vectors generated by libsodium directly
|
||||||
|
- [x] Base64 helpers (`fromBase64`, `toBase64`, `toBase64URL`) accepting all
|
||||||
|
four sodium variants on input
|
||||||
|
|
||||||
Phase 3: SRP + auth
|
Phase 3: SRP + auth
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,21 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Honour the leading-underscore convention for intentionally
|
||||||
|
// unused parameters and variables. Stub functions in
|
||||||
|
// src/crypto/ during the TDD red phase keep their parameter
|
||||||
|
// names (prefixed with _) so the signature is visible to the
|
||||||
|
// implementer.
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,11 +29,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.38.0",
|
"@eslint/js": "9.38.0",
|
||||||
|
"@types/libsodium-wrappers-sumo": "0.8.2",
|
||||||
"@types/node": "22.18.13",
|
"@types/node": "22.18.13",
|
||||||
"eslint": "9.38.0",
|
"eslint": "9.38.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.46.2",
|
"typescript-eslint": "8.46.2",
|
||||||
"vitest": "2.1.9"
|
"vitest": "2.1.9"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-wrappers-sumo": "0.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/crypto/box.ts
Normal file
19
src/crypto/box.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
|
||||||
|
// XSalsa20-Poly1305 secretbox decryption. Throws on authentication failure
|
||||||
|
// (tampered ciphertext, wrong key, wrong nonce). Used pervasively by Ente
|
||||||
|
// for sealed key material and metadata.
|
||||||
|
export const decryptBox = (
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array => sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||||
|
|
||||||
|
// Anonymous sealed-box (X25519 + XSalsa20-Poly1305) decryption. Used to
|
||||||
|
// recover the auth token returned by login. The recipient (us) needs both
|
||||||
|
// halves of the keypair; sealed-box is anonymous on the sender side.
|
||||||
|
export const decryptSealed = (
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
publicKey: Uint8Array,
|
||||||
|
secretKey: Uint8Array,
|
||||||
|
): Uint8Array => sodium.crypto_box_seal_open(ciphertext, publicKey, secretKey);
|
||||||
31
src/crypto/encoding.ts
Normal file
31
src/crypto/encoding.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
|
||||||
|
export const toBase64 = (b: Uint8Array): string =>
|
||||||
|
sodium.to_base64(b, sodium.base64_variants.ORIGINAL);
|
||||||
|
|
||||||
|
export const toBase64URL = (b: Uint8Array): string =>
|
||||||
|
sodium.to_base64(b, sodium.base64_variants.URLSAFE_NO_PADDING);
|
||||||
|
|
||||||
|
// Ente uses standard base64 for most fields, URL-safe (with padding stripped)
|
||||||
|
// for the auth token. Rather than make callers specify, fromBase64 accepts
|
||||||
|
// any of the four variants libsodium understands and returns the bytes.
|
||||||
|
const VARIANTS = [
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
sodium.base64_variants.ORIGINAL_NO_PADDING,
|
||||||
|
sodium.base64_variants.URLSAFE,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const fromBase64 = (s: string): Uint8Array => {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const variant of VARIANTS) {
|
||||||
|
try {
|
||||||
|
return sodium.from_base64(s, variant);
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error("invalid base64 input");
|
||||||
|
};
|
||||||
11
src/crypto/index.ts
Normal file
11
src/crypto/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { init } from "./sodium.js";
|
||||||
|
export { fromBase64, toBase64, toBase64URL } from "./encoding.js";
|
||||||
|
export { deriveKEK, deriveLoginSubkey } from "./kdf.js";
|
||||||
|
export { decryptBox, decryptSealed } from "./box.js";
|
||||||
|
export {
|
||||||
|
initStreamPull,
|
||||||
|
pullStreamChunk,
|
||||||
|
STREAM_CHUNK_OVERHEAD,
|
||||||
|
STREAM_CHUNK_SIZE,
|
||||||
|
type StreamPullState,
|
||||||
|
} from "./stream.js";
|
||||||
25
src/crypto/kdf.ts
Normal file
25
src/crypto/kdf.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
|
||||||
|
// Argon2id over (password, salt, opsLimit, memLimit) producing a 32-byte
|
||||||
|
// Key Encryption Key. Parameters come from the server; quack passes them
|
||||||
|
// straight through. memLimit is in bytes, opsLimit is the iteration count.
|
||||||
|
export const deriveKEK = async (
|
||||||
|
password: string,
|
||||||
|
salt: Uint8Array,
|
||||||
|
opsLimit: number,
|
||||||
|
memLimit: number,
|
||||||
|
): Promise<Uint8Array> =>
|
||||||
|
sodium.crypto_pwhash(
|
||||||
|
32,
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
opsLimit,
|
||||||
|
memLimit,
|
||||||
|
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
||||||
|
);
|
||||||
|
|
||||||
|
// BLAKE2b-based KDF from the KEK. Subkey id 1 and context "loginctx" are
|
||||||
|
// fixed by the upstream Ente clients; the first 16 bytes of the 32-byte
|
||||||
|
// output are used as the SRP password. Any deviation breaks login.
|
||||||
|
export const deriveLoginSubkey = (kek: Uint8Array): Uint8Array =>
|
||||||
|
sodium.crypto_kdf_derive_from_key(32, 1, "loginctx", kek).slice(0, 16);
|
||||||
12
src/crypto/sodium.ts
Normal file
12
src/crypto/sodium.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
|
||||||
|
// libsodium ships as WebAssembly and loads asynchronously. The `sodium.ready`
|
||||||
|
// promise resolves once the WASM module is instantiated and the bindings are
|
||||||
|
// safe to call. After it resolves, all subsequent calls are synchronous.
|
||||||
|
//
|
||||||
|
// `init` is the single front door for that. Code that needs crypto awaits
|
||||||
|
// init() once before doing anything. Repeat calls await the same promise,
|
||||||
|
// so they are effectively free after the first.
|
||||||
|
export const init = async (): Promise<void> => {
|
||||||
|
await sodium.ready;
|
||||||
|
};
|
||||||
38
src/crypto/stream.ts
Normal file
38
src/crypto/stream.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
|
||||||
|
// Plaintext chunk size used by Ente for file content streams. Hard-coded by
|
||||||
|
// the server; clients must match.
|
||||||
|
export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Per-chunk overhead added by libsodium's secretstream construction:
|
||||||
|
// 16 bytes of Poly1305 tag plus 1 byte of secretstream tag.
|
||||||
|
export const STREAM_CHUNK_OVERHEAD = 17;
|
||||||
|
|
||||||
|
// Opaque handle to libsodium's secretstream pull state. Threaded through
|
||||||
|
// successive pullStreamChunk calls.
|
||||||
|
export type StreamPullState = sodium.StateAddress;
|
||||||
|
|
||||||
|
// Initialise a pull stream from the per-file decryption header and the
|
||||||
|
// per-file key.
|
||||||
|
export const initStreamPull = (
|
||||||
|
header: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): StreamPullState =>
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);
|
||||||
|
|
||||||
|
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
|
||||||
|
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
|
||||||
|
// stream ended on TAG_FINAL to detect truncation.
|
||||||
|
export const pullStreamChunk = (
|
||||||
|
state: StreamPullState,
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
): { plaintext: Uint8Array; tag: number } => {
|
||||||
|
const result = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
state,
|
||||||
|
ciphertext,
|
||||||
|
);
|
||||||
|
if (result === false) {
|
||||||
|
throw new Error("secretstream chunk authentication failed");
|
||||||
|
}
|
||||||
|
return { plaintext: result.message, tag: result.tag };
|
||||||
|
};
|
||||||
160
test/crypto/box.test.ts
Normal file
160
test/crypto/box.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `crypto.decryptBox` and `crypto.decryptSealed`.
|
||||||
|
*
|
||||||
|
* These cover the two asymmetric-and-symmetric "box" primitives quack uses
|
||||||
|
* to unwrap key material from Ente:
|
||||||
|
*
|
||||||
|
* - `decryptBox`: secretbox decryption. Used everywhere a small payload
|
||||||
|
* is sealed under a single shared key. Specifically:
|
||||||
|
*
|
||||||
|
* master key = decryptBox(encryptedKey, keyDecryptionNonce, kek)
|
||||||
|
* secret key = decryptBox(encryptedSecretKey, secretKeyDecryptionNonce, masterKey)
|
||||||
|
* collection key = decryptBox(coll.encryptedKey, coll.keyDecryptionNonce, masterKey)
|
||||||
|
* file key = decryptBox(file.encryptedKey, file.keyDecryptionNonce, collectionKey)
|
||||||
|
* file metadata = decryptBox(metadata.encryptedData, metadata.decryptionHeader, fileKey)
|
||||||
|
*
|
||||||
|
* - `decryptSealed`: anonymous sealed-box decryption. Used exactly once,
|
||||||
|
* to recover the auth token returned by login:
|
||||||
|
*
|
||||||
|
* authToken = decryptSealed(encryptedToken, publicKey, secretKey)
|
||||||
|
*
|
||||||
|
* Encryption is server-side; quack only ever decrypts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { decryptBox, decryptSealed, init } from "../../src/crypto/index.js";
|
||||||
|
|
||||||
|
describe("crypto.decryptBox (XSalsa20-Poly1305 secretbox)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts ciphertext produced by sodium.crypto_secretbox_easy", () => {
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const plaintext = new TextEncoder().encode("hello, ente");
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||||
|
|
||||||
|
const got = decryptBox(ciphertext, nonce, key);
|
||||||
|
expect(new TextDecoder().decode(got)).toBe("hello, ente");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts a zero-byte plaintext", () => {
|
||||||
|
// Edge case: zero-length plaintext still produces a 16-byte
|
||||||
|
// Poly1305 tag, so the ciphertext is 16 bytes and decryption must
|
||||||
|
// succeed.
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(
|
||||||
|
new Uint8Array(0),
|
||||||
|
nonce,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
const got = decryptBox(ciphertext, nonce, key);
|
||||||
|
expect(got.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the ciphertext has been tampered with", () => {
|
||||||
|
// Authentication is the whole point. A single-bit flip must
|
||||||
|
// reject. If this test ever passes silently, the wrapper has lost
|
||||||
|
// the Poly1305 check and we have a security regression.
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(
|
||||||
|
new Uint8Array([1, 2, 3, 4, 5]),
|
||||||
|
nonce,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
ciphertext[0] = ciphertext[0]! ^ 0x01;
|
||||||
|
expect(() => decryptBox(ciphertext, nonce, key)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the wrong key is supplied", () => {
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
const wrongKey = sodium.crypto_secretbox_keygen();
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(
|
||||||
|
new Uint8Array([9, 9, 9]),
|
||||||
|
nonce,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
expect(() => decryptBox(ciphertext, nonce, wrongKey)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the nonce is wrong", () => {
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const wrongNonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
nonce,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
expect(() => decryptBox(ciphertext, wrongNonce, key)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crypto.decryptSealed (anonymous box)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed-box (`crypto_box_seal`) is anonymous public-key encryption: a
|
||||||
|
* sender encrypts to a recipient public key without authenticating its
|
||||||
|
* own identity. The recipient decrypts using both halves of its own
|
||||||
|
* X25519 keypair.
|
||||||
|
*
|
||||||
|
* Ente's server uses this to deliver the auth token after login: the
|
||||||
|
* server seals the token to the user's published public key. The user
|
||||||
|
* recovers the secret key from a secretbox under the master key (see
|
||||||
|
* decryptBox above), then opens the sealed token.
|
||||||
|
*/
|
||||||
|
it("decrypts a sealed box produced by sodium.crypto_box_seal", () => {
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
const message = new TextEncoder().encode("auth-token-payload");
|
||||||
|
const sealed = sodium.crypto_box_seal(message, kp.publicKey);
|
||||||
|
|
||||||
|
const got = decryptSealed(sealed, kp.publicKey, kp.privateKey);
|
||||||
|
expect(new TextDecoder().decode(got)).toBe("auth-token-payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when given the wrong keypair", () => {
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
const otherKp = sodium.crypto_box_keypair();
|
||||||
|
const sealed = sodium.crypto_box_seal(
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
kp.publicKey,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
decryptSealed(sealed, otherKp.publicKey, otherKp.privateKey),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the ciphertext has been tampered with", () => {
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
const sealed = sodium.crypto_box_seal(
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
kp.publicKey,
|
||||||
|
);
|
||||||
|
sealed[sealed.length - 1] = sealed[sealed.length - 1]! ^ 0x01;
|
||||||
|
expect(() =>
|
||||||
|
decryptSealed(sealed, kp.publicKey, kp.privateKey),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
test/crypto/encoding.test.ts
Normal file
89
test/crypto/encoding.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `crypto.fromBase64`, `crypto.toBase64`, and
|
||||||
|
* `crypto.toBase64URL`.
|
||||||
|
*
|
||||||
|
* Ente delivers most binary fields as standard base64 strings (with `+`,
|
||||||
|
* `/`, and `=` padding). A few fields, notably the auth token returned by
|
||||||
|
* the login flow, are URL-safe base64 (with `-` and `_` instead of `+` and
|
||||||
|
* `/`, and stripped padding). quack must accept both forms on input and
|
||||||
|
* produce the right form on output.
|
||||||
|
*
|
||||||
|
* These tests pin the contract:
|
||||||
|
* - `toBase64(b)` produces standard base64 with padding.
|
||||||
|
* - `toBase64URL(b)` produces URL-safe base64 without padding.
|
||||||
|
* - `fromBase64(s)` accepts both forms transparently and round-trips.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
fromBase64,
|
||||||
|
init,
|
||||||
|
toBase64,
|
||||||
|
toBase64URL,
|
||||||
|
} from "../../src/crypto/index.js";
|
||||||
|
|
||||||
|
describe("crypto encoding helpers", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The test input contains bytes that produce `+`, `/`, and `=` in
|
||||||
|
* standard base64. Specifically the bytes `0xFB 0xFF 0xBF` encode to
|
||||||
|
* `+/+/` in standard base64 and `-_-_` in URL-safe base64. This makes
|
||||||
|
* the alphabet difference observable.
|
||||||
|
*/
|
||||||
|
const BYTES_WITH_AMBIGUOUS_CHARS = new Uint8Array([0xfb, 0xff, 0xbf]);
|
||||||
|
|
||||||
|
it("round-trips arbitrary bytes through standard base64", () => {
|
||||||
|
const original = new Uint8Array([0, 1, 2, 127, 128, 250, 255]);
|
||||||
|
const encoded = toBase64(original);
|
||||||
|
expect(typeof encoded).toBe("string");
|
||||||
|
expect(fromBase64(encoded)).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toBase64 produces a standard-alphabet string", () => {
|
||||||
|
// Standard base64 may contain `+`, `/`, and trailing `=`. URL-safe
|
||||||
|
// base64 is forbidden from containing those characters. We test
|
||||||
|
// that toBase64 chose the standard alphabet.
|
||||||
|
const encoded = toBase64(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
// For these specific bytes the encoding contains both `+` and `/`,
|
||||||
|
// proving the alphabet is the standard one.
|
||||||
|
expect(encoded).toMatch(/[+/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toBase64URL produces a URL-safe string with no padding", () => {
|
||||||
|
const encoded = toBase64URL(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
// URL-safe alphabet: no `+`, `/`, or `=`.
|
||||||
|
expect(encoded).not.toMatch(/[+/=]/);
|
||||||
|
// The substitutions `-` and `_` should appear.
|
||||||
|
expect(encoded).toMatch(/[-_]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fromBase64 accepts standard input", () => {
|
||||||
|
const standard = toBase64(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
expect(fromBase64(standard)).toEqual(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fromBase64 accepts URL-safe input", () => {
|
||||||
|
const urlSafe = toBase64URL(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
expect(fromBase64(urlSafe)).toEqual(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fromBase64 accepts URL-safe input even without padding", () => {
|
||||||
|
// Construct a URL-safe form with the padding stripped, as Ente
|
||||||
|
// delivers it. `fromBase64` must still decode it correctly.
|
||||||
|
const stripped = toBase64URL(BYTES_WITH_AMBIGUOUS_CHARS).replace(
|
||||||
|
/=+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
expect(fromBase64(stripped)).toEqual(BYTES_WITH_AMBIGUOUS_CHARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fromBase64 rejects garbage", () => {
|
||||||
|
// Non-base64 characters should not silently decode to something. We
|
||||||
|
// do not commit to the exact error type but we do commit that the
|
||||||
|
// call cannot return data successfully.
|
||||||
|
expect(() => fromBase64("!!! not base64 !!!")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
test/crypto/init.test.ts
Normal file
38
test/crypto/init.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `crypto.init()`.
|
||||||
|
*
|
||||||
|
* libsodium ships as WebAssembly. The bindings (`libsodium-wrappers-sumo`)
|
||||||
|
* load asynchronously: the runtime must `await sodium.ready` once before any
|
||||||
|
* crypto call is safe. quack hides that detail behind a single
|
||||||
|
* `init()` function.
|
||||||
|
*
|
||||||
|
* Every other test in `test/crypto/**` calls `init()` in `beforeAll`. New
|
||||||
|
* code that needs crypto should do the same. Calling it more than once is
|
||||||
|
* cheap and intentional; the second call returns the same already-resolved
|
||||||
|
* promise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { init } from "../../src/crypto/index.js";
|
||||||
|
|
||||||
|
describe("crypto.init", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves without throwing", async () => {
|
||||||
|
// The success criterion is simply that the promise resolves. If
|
||||||
|
// libsodium's WASM fails to load, `init()` rejects and this test
|
||||||
|
// fails.
|
||||||
|
await expect(init()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: repeated calls are safe", async () => {
|
||||||
|
// Code paths in this library may call `init()` defensively (e.g. a
|
||||||
|
// `Client` constructor that doesn't know whether the caller already
|
||||||
|
// initialised). Repeated calls must therefore be harmless.
|
||||||
|
await init();
|
||||||
|
await init();
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
});
|
||||||
136
test/crypto/kdf.test.ts
Normal file
136
test/crypto/kdf.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `crypto.deriveKEK` and `crypto.deriveLoginSubkey`.
|
||||||
|
*
|
||||||
|
* These two functions implement the password-side of Ente's authentication
|
||||||
|
* flow:
|
||||||
|
*
|
||||||
|
* 1. The user types a password.
|
||||||
|
* 2. `deriveKEK` runs Argon2id over the password using a server-issued
|
||||||
|
* 16-byte salt and server-issued mem/ops parameters. The result is a
|
||||||
|
* 32-byte Key Encryption Key (KEK).
|
||||||
|
* 3. `deriveLoginSubkey` runs libsodium's BLAKE2b-based KDF over the KEK
|
||||||
|
* with a fixed subkey id and context, takes the first 16 bytes, and
|
||||||
|
* uses that as the password input to SRP-6a. The user's password
|
||||||
|
* itself never touches the network.
|
||||||
|
*
|
||||||
|
* Both derivations must match the upstream Ente implementations bit for
|
||||||
|
* bit. If they drift, the SRP handshake fails and the user cannot log in.
|
||||||
|
* The tests below pin the exact algorithms and parameters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { deriveKEK, deriveLoginSubkey, init } from "../../src/crypto/index.js";
|
||||||
|
|
||||||
|
describe("crypto.deriveKEK (Argon2id)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap parameters used so the test suite stays under the 30-second
|
||||||
|
* budget. The real production parameters Ente uses are larger
|
||||||
|
* (memLimit up to 1 GiB, opsLimit 3-16). The algorithm is the same
|
||||||
|
* regardless of parameters.
|
||||||
|
*/
|
||||||
|
const TEST_OPS = 2;
|
||||||
|
const TEST_MEM = 64 * 1024 * 1024; // 64 MiB
|
||||||
|
|
||||||
|
it("matches sodium.crypto_pwhash with Argon2id, 32-byte output", async () => {
|
||||||
|
// The contract: deriveKEK is exactly
|
||||||
|
// crypto_pwhash(32, password, salt, opsLimit, memLimit, ARGON2ID13)
|
||||||
|
// No wrapper-side normalisation, no parameter mangling, nothing
|
||||||
|
// implicit. We compute the expected value with sodium directly and
|
||||||
|
// assert byte equality.
|
||||||
|
const password = "correct horse battery staple";
|
||||||
|
const salt = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
|
||||||
|
salt.fill(0x42); // any salt works, we just need it to be deterministic
|
||||||
|
|
||||||
|
const expected = sodium.crypto_pwhash(
|
||||||
|
32,
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
TEST_OPS,
|
||||||
|
TEST_MEM,
|
||||||
|
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
||||||
|
);
|
||||||
|
|
||||||
|
const got = await deriveKEK(password, salt, TEST_OPS, TEST_MEM);
|
||||||
|
expect(got).toEqual(expected);
|
||||||
|
expect(got.length).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces different keys for different passwords with the same salt", async () => {
|
||||||
|
const salt = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
|
||||||
|
salt.fill(0x01);
|
||||||
|
const a = await deriveKEK("alpha", salt, TEST_OPS, TEST_MEM);
|
||||||
|
const b = await deriveKEK("beta", salt, TEST_OPS, TEST_MEM);
|
||||||
|
expect(a).not.toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces different keys for the same password with different salts", async () => {
|
||||||
|
const password = "same password";
|
||||||
|
const saltA = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
|
||||||
|
saltA.fill(0x01);
|
||||||
|
const saltB = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
|
||||||
|
saltB.fill(0x02);
|
||||||
|
const a = await deriveKEK(password, saltA, TEST_OPS, TEST_MEM);
|
||||||
|
const b = await deriveKEK(password, saltB, TEST_OPS, TEST_MEM);
|
||||||
|
expect(a).not.toEqual(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crypto.deriveLoginSubkey", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact derivation Ente uses, taken from the web client and the Go
|
||||||
|
* CLI:
|
||||||
|
*
|
||||||
|
* subkey = crypto_kdf_derive_from_key(
|
||||||
|
* outputLen = 32,
|
||||||
|
* subkeyId = 1,
|
||||||
|
* context = "loginctx",
|
||||||
|
* key = kek,
|
||||||
|
* ).slice(0, 16)
|
||||||
|
*
|
||||||
|
* The 32-byte output is truncated to 16 bytes; that 16-byte value is
|
||||||
|
* the password input to SRP-6a. Any deviation in subkey id, context,
|
||||||
|
* or truncation length breaks login.
|
||||||
|
*/
|
||||||
|
it("derives 16 bytes via KDF subkey 1, ctx 'loginctx'", () => {
|
||||||
|
const kek = new Uint8Array(32);
|
||||||
|
for (let i = 0; i < 32; i++) kek[i] = i; // 0x00..0x1f
|
||||||
|
|
||||||
|
const expected32 = sodium.crypto_kdf_derive_from_key(
|
||||||
|
32,
|
||||||
|
1,
|
||||||
|
"loginctx",
|
||||||
|
kek,
|
||||||
|
);
|
||||||
|
const expected16 = expected32.slice(0, 16);
|
||||||
|
|
||||||
|
const got = deriveLoginSubkey(kek);
|
||||||
|
expect(got.length).toBe(16);
|
||||||
|
expect(got).toEqual(expected16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a different subkey for a different KEK", () => {
|
||||||
|
const kekA = new Uint8Array(32).fill(0x11);
|
||||||
|
const kekB = new Uint8Array(32).fill(0x22);
|
||||||
|
const a = deriveLoginSubkey(kekA);
|
||||||
|
const b = deriveLoginSubkey(kekB);
|
||||||
|
expect(a).not.toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic for a given KEK", () => {
|
||||||
|
const kek = new Uint8Array(32).fill(0x37);
|
||||||
|
const a = deriveLoginSubkey(kek);
|
||||||
|
const b = deriveLoginSubkey(kek);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
172
test/crypto/stream.test.ts
Normal file
172
test/crypto/stream.test.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `crypto.initStreamPull` and `crypto.pullStreamChunk`.
|
||||||
|
*
|
||||||
|
* Ente encrypts file content with libsodium's secretstream construction
|
||||||
|
* (XChaCha20-Poly1305) in chunked mode. Each plaintext chunk is at most
|
||||||
|
* `STREAM_CHUNK_SIZE` bytes (4 MiB); each ciphertext chunk is exactly 17
|
||||||
|
* bytes longer than its plaintext (16-byte Poly1305 tag plus a 1-byte
|
||||||
|
* secretstream tag).
|
||||||
|
*
|
||||||
|
* The decryption header is delivered separately from the encrypted body in
|
||||||
|
* the file metadata (`file.file.decryptionHeader`). Once `initStreamPull`
|
||||||
|
* has consumed it, the body is read in order, one ciphertext chunk at a
|
||||||
|
* time, and each chunk is fed to `pullStreamChunk`. The library exposes
|
||||||
|
* the secretstream tag on each pulled chunk so the caller can verify the
|
||||||
|
* stream ended on a `TAG_FINAL` chunk and was therefore not truncated.
|
||||||
|
*
|
||||||
|
* These tests pin:
|
||||||
|
* - The chunk-size constants match Ente's expectations.
|
||||||
|
* - The pull state can decrypt a multi-chunk stream produced by
|
||||||
|
* sodium.crypto_secretstream_xchacha20poly1305_push, in order.
|
||||||
|
* - The tag byte is propagated to the caller.
|
||||||
|
* - Tampered or out-of-order ciphertext is rejected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
init,
|
||||||
|
initStreamPull,
|
||||||
|
pullStreamChunk,
|
||||||
|
STREAM_CHUNK_OVERHEAD,
|
||||||
|
STREAM_CHUNK_SIZE,
|
||||||
|
} from "../../src/crypto/index.js";
|
||||||
|
|
||||||
|
describe("crypto stream constants", () => {
|
||||||
|
/**
|
||||||
|
* These constants match the values hard-coded into Ente's web client
|
||||||
|
* and Go CLI. If Ente ever changes them server-side, every client
|
||||||
|
* must change in lockstep.
|
||||||
|
*/
|
||||||
|
it("STREAM_CHUNK_SIZE is 4 MiB", () => {
|
||||||
|
expect(STREAM_CHUNK_SIZE).toBe(4 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STREAM_CHUNK_OVERHEAD is 17 bytes", () => {
|
||||||
|
expect(STREAM_CHUNK_OVERHEAD).toBe(17);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crypto.initStreamPull / pullStreamChunk", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: encrypt a sequence of plaintext chunks with sodium's push
|
||||||
|
* API and return the header plus the encrypted chunks. Marks the
|
||||||
|
* final chunk with `TAG_FINAL` (3); intermediate chunks use
|
||||||
|
* `TAG_MESSAGE` (0).
|
||||||
|
*/
|
||||||
|
const encryptChunks = (
|
||||||
|
key: Uint8Array,
|
||||||
|
chunks: Uint8Array[],
|
||||||
|
): { header: Uint8Array; encrypted: Uint8Array[] } => {
|
||||||
|
const push =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||||
|
const encrypted: Uint8Array[] = [];
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const isLast = i === chunks.length - 1;
|
||||||
|
const tag = isLast
|
||||||
|
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
||||||
|
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
encrypted.push(
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
push.state,
|
||||||
|
chunks[i]!,
|
||||||
|
null,
|
||||||
|
tag,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { header: push.header, encrypted };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("decrypts a single-chunk stream marked TAG_FINAL", () => {
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const plaintext = new TextEncoder().encode("a small file's contents");
|
||||||
|
const { header, encrypted } = encryptChunks(key, [plaintext]);
|
||||||
|
|
||||||
|
const state = initStreamPull(header, key);
|
||||||
|
const result = pullStreamChunk(state, encrypted[0]!);
|
||||||
|
expect(result.plaintext).toEqual(plaintext);
|
||||||
|
expect(result.tag).toBe(
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts a multi-chunk stream in order, exposing tags per chunk", () => {
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const plaintexts = [
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
new Uint8Array([4, 5, 6, 7, 8]),
|
||||||
|
new Uint8Array([9, 10]),
|
||||||
|
];
|
||||||
|
const { header, encrypted } = encryptChunks(key, plaintexts);
|
||||||
|
|
||||||
|
const state = initStreamPull(header, key);
|
||||||
|
const results = encrypted.map((c) => pullStreamChunk(state, c));
|
||||||
|
|
||||||
|
// Plaintext is recovered chunk-for-chunk, in order.
|
||||||
|
expect(results.map((r) => r.plaintext)).toEqual(plaintexts);
|
||||||
|
|
||||||
|
// Intermediate chunks carry TAG_MESSAGE; the last carries TAG_FINAL.
|
||||||
|
// The caller can use this to detect a truncated stream: if the
|
||||||
|
// last chunk seen does not have TAG_FINAL, the body was cut off.
|
||||||
|
const TAG_MESSAGE =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
const TAG_FINAL =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
|
||||||
|
expect(results[0]!.tag).toBe(TAG_MESSAGE);
|
||||||
|
expect(results[1]!.tag).toBe(TAG_MESSAGE);
|
||||||
|
expect(results[2]!.tag).toBe(TAG_FINAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a tampered ciphertext chunk", () => {
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { header, encrypted } = encryptChunks(key, [
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
]);
|
||||||
|
encrypted[0]![0] = encrypted[0]![0]! ^ 0x01;
|
||||||
|
|
||||||
|
const state = initStreamPull(header, key);
|
||||||
|
expect(() => pullStreamChunk(state, encrypted[0]!)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a chunk decrypted with the wrong key", () => {
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const wrongKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { header, encrypted } = encryptChunks(key, [
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
]);
|
||||||
|
const state = initStreamPull(header, wrongKey);
|
||||||
|
expect(() => pullStreamChunk(state, encrypted[0]!)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects chunks pulled out of order", () => {
|
||||||
|
// The secretstream construction binds each chunk to its position in
|
||||||
|
// the stream. Feeding chunk 1's ciphertext after chunk 0 was
|
||||||
|
// skipped, or in the wrong order, must fail authentication.
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { header, encrypted } = encryptChunks(key, [
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
new Uint8Array([4, 5, 6]),
|
||||||
|
]);
|
||||||
|
const state = initStreamPull(header, key);
|
||||||
|
// Skip chunk 0 entirely and try to pull chunk 1 first.
|
||||||
|
expect(() => pullStreamChunk(state, encrypted[1]!)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ciphertext chunks are exactly STREAM_CHUNK_OVERHEAD longer than plaintext", () => {
|
||||||
|
// Sanity check on the overhead constant. If libsodium ever changes
|
||||||
|
// this (it won't), the constant in our crypto module must change
|
||||||
|
// with it.
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const plaintext = new Uint8Array(123).fill(0x55);
|
||||||
|
const { encrypted } = encryptChunks(key, [plaintext]);
|
||||||
|
expect(encrypted[0]!.length).toBe(
|
||||||
|
plaintext.length + STREAM_CHUNK_OVERHEAD,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
yarn.lock
19
yarn.lock
@@ -389,6 +389,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||||
|
|
||||||
|
"@types/libsodium-wrappers-sumo@0.8.2":
|
||||||
|
version "0.8.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz#488e8747fbb982fe901020b5afeaddfa63da6830"
|
||||||
|
integrity sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ==
|
||||||
|
dependencies:
|
||||||
|
libsodium-wrappers-sumo "*"
|
||||||
|
|
||||||
"@types/node@22.18.13":
|
"@types/node@22.18.13":
|
||||||
version "22.18.13"
|
version "22.18.13"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.13.tgz#a037c4f474b860be660e05dbe92a9ef945472e28"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.13.tgz#a037c4f474b860be660e05dbe92a9ef945472e28"
|
||||||
@@ -1030,6 +1037,18 @@ levn@^0.4.1:
|
|||||||
prelude-ls "^1.2.1"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
type-check "~0.4.0"
|
||||||
|
|
||||||
|
libsodium-sumo@^0.8.0:
|
||||||
|
version "0.8.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz#6d4687781fa0ad398af14a7df872d5c27cf8cd31"
|
||||||
|
integrity sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==
|
||||||
|
|
||||||
|
libsodium-wrappers-sumo@*, libsodium-wrappers-sumo@0.8.4:
|
||||||
|
version "0.8.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz#6656a3e7e0551ecce08ddee4bfb501a092eac6fa"
|
||||||
|
integrity sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==
|
||||||
|
dependencies:
|
||||||
|
libsodium-sumo "^0.8.0"
|
||||||
|
|
||||||
locate-path@^6.0.0:
|
locate-path@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||||
|
|||||||
Reference in New Issue
Block a user