Compare commits
No commits in common. "a0a05da03c9e185d7b005556e337e811e335ce27" and "b34475b4fb1dd7a012bf7fd872fa4bb097399939" have entirely different histories.
a0a05da03c
...
b34475b4fb
@ -1,2 +0,0 @@
|
|||||||
ui/public/wasm/
|
|
||||||
ui/public/wasm/
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,5 +7,3 @@ infra/compose*.yml
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
target/
|
|
||||||
wasm/sss_recovery/target/
|
|
||||||
|
|||||||
43
deno.json
43
deno.json
@ -1,29 +1,40 @@
|
|||||||
{
|
{
|
||||||
|
"workspace": [
|
||||||
|
"./sdk",
|
||||||
|
"./server",
|
||||||
|
"./ui"
|
||||||
|
],
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --watch -A --unstable-ffi server/main.ts",
|
|
||||||
"start": "deno run -A --unstable-ffi server/main.ts",
|
|
||||||
"test": "deno test -A --unstable-ffi",
|
|
||||||
"lint": "deno lint",
|
"lint": "deno lint",
|
||||||
"check": "deno check **/*.ts **/*.tsx"
|
"fmt": "deno fmt",
|
||||||
|
"check": "deno check server/**/*.ts sdk/**/*.ts ui/**/*.ts infra/**/*.ts",
|
||||||
|
"test": "deno test -A",
|
||||||
|
"setup": "deno run -A infra/setup.ts",
|
||||||
|
"release": "deno run -A infra/setup.ts release"
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"ui/public/wasm/",
|
"sdk/gen"
|
||||||
"sdk/gen/",
|
],
|
||||||
"ui/public/ui/utils/",
|
"rules": {
|
||||||
"wasm/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fmt": {
|
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"ui/public/wasm/",
|
"no-empty",
|
||||||
"sdk/gen/",
|
"no-import-prefix",
|
||||||
"ui/public/ui/utils/",
|
"no-unversioned-import",
|
||||||
"wasm/"
|
"no-explicit-any",
|
||||||
|
"require-await"
|
||||||
]
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx"
|
"jsxImportSource": "jsr:@hono/hono@4/jsx"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@bufbuild/protobuf": "npm:@bufbuild/protobuf@^1.10.0",
|
||||||
|
"@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.7",
|
||||||
|
"@connectrpc/connect": "npm:@connectrpc/connect@^1.4.0",
|
||||||
|
"@connectrpc/connect-node": "npm:@connectrpc/connect-node@^1.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
deno.lock
generated
1
deno.lock
generated
@ -15,7 +15,6 @@
|
|||||||
"jsr:@std/assert@1": "1.0.19",
|
"jsr:@std/assert@1": "1.0.19",
|
||||||
"jsr:@std/assert@^1.0.19": "1.0.19",
|
"jsr:@std/assert@^1.0.19": "1.0.19",
|
||||||
"jsr:@std/assert@~1.0.6": "1.0.19",
|
"jsr:@std/assert@~1.0.6": "1.0.19",
|
||||||
"jsr:@std/encoding@*": "1.0.10",
|
|
||||||
"jsr:@std/encoding@1": "1.0.10",
|
"jsr:@std/encoding@1": "1.0.10",
|
||||||
"jsr:@std/encoding@~1.0.5": "1.0.10",
|
"jsr:@std/encoding@~1.0.5": "1.0.10",
|
||||||
"jsr:@std/fmt@0.225.2": "0.225.2",
|
"jsr:@std/fmt@0.225.2": "0.225.2",
|
||||||
|
|||||||
171
server/audit.ts
171
server/audit.ts
@ -1,12 +1,4 @@
|
|||||||
import { encodeBase64 } from "jsr:@std/encoding/base64";
|
|
||||||
import { encodeHex } from "jsr:@std/encoding/hex";
|
|
||||||
import { sqlWrapper } from "./db.ts";
|
import { sqlWrapper } from "./db.ts";
|
||||||
import { buildMerkleTree, leafHash } from "./audit_merkle.ts";
|
|
||||||
import { fetchSpiffeIdentity } from "./spire_ffi.ts";
|
|
||||||
import { valkey } from "./valkey.ts";
|
|
||||||
|
|
||||||
let batcherIntervalId: number | null = null;
|
|
||||||
let isFlushing = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SIDE EFFECT: Asynchronously logs an audit record to the database.
|
* SIDE EFFECT: Asynchronously logs an audit record to the database.
|
||||||
@ -21,171 +13,16 @@ export let auditLog = function auditLog(
|
|||||||
ipAddress: string,
|
ipAddress: string,
|
||||||
): void {
|
): void {
|
||||||
// Fire and forget
|
// Fire and forget
|
||||||
(async () => {
|
sqlWrapper.sql`
|
||||||
try {
|
INSERT INTO audit_records (user_id, action, resource, details, ip_address)
|
||||||
const entryObj = {
|
|
||||||
userId,
|
|
||||||
action,
|
|
||||||
resource,
|
|
||||||
details,
|
|
||||||
ipAddress,
|
|
||||||
timestamp: Date.now(), // Rough tie breaker if needed, but not strictly RFC6962 entry structure
|
|
||||||
};
|
|
||||||
const entryStr = JSON.stringify(entryObj);
|
|
||||||
const entryBytes = new TextEncoder().encode(entryStr);
|
|
||||||
const hashBuffer = await leafHash(entryBytes);
|
|
||||||
const leafHashHex = encodeHex(hashBuffer);
|
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
|
||||||
INSERT INTO audit_records (user_id, action, resource, details, ip_address, leaf_hash)
|
|
||||||
VALUES (${userId}, ${action}, ${resource}, ${
|
VALUES (${userId}, ${action}, ${resource}, ${
|
||||||
details ? JSON.stringify(details) : null
|
details ? JSON.stringify(details) : null
|
||||||
}, ${ipAddress}, ${leafHashHex})
|
}, ${ipAddress})
|
||||||
`;
|
`.catch((error: any) => {
|
||||||
} catch (error: any) {
|
|
||||||
console.error("[Audit Logger] Failed to insert audit record:", error);
|
console.error("[Audit Logger] Failed to insert audit record:", error);
|
||||||
}
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function flush(): Promise<void> {
|
|
||||||
if (isFlushing) return;
|
|
||||||
isFlushing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Get the latest STH tree_size
|
|
||||||
const sthResult = await sqlWrapper.sql`
|
|
||||||
SELECT tree_size FROM audit_sths ORDER BY tree_size DESC LIMIT 1
|
|
||||||
`;
|
|
||||||
const lastTreeSize = sthResult.length > 0
|
|
||||||
? parseInt(sthResult[0].tree_size, 10)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// 2. Fetch new leaf hashes
|
|
||||||
// We order by created_at and id to append to the tree deterministically.
|
|
||||||
const allRecordsResult = await sqlWrapper.sql`
|
|
||||||
SELECT leaf_hash FROM audit_records ORDER BY created_at ASC, id ASC
|
|
||||||
`;
|
|
||||||
|
|
||||||
const currentTreeSize = allRecordsResult.length;
|
|
||||||
|
|
||||||
if (currentTreeSize > lastTreeSize) {
|
|
||||||
// 3. Compute Merkle Tree Root
|
|
||||||
const leafHashes = allRecordsResult.map((row: any) => {
|
|
||||||
// Convert hex string back to Uint8Array
|
|
||||||
const hex = row.leaf_hash;
|
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootHashBytes = await buildMerkleTree(leafHashes);
|
|
||||||
const rootHashHex = encodeHex(rootHashBytes);
|
|
||||||
|
|
||||||
// 4. Sign the STH
|
|
||||||
let svidData;
|
|
||||||
try {
|
|
||||||
svidData = await fetchSpiffeIdentity();
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback for hermetic tests when mocked FFI might throw
|
|
||||||
svidData = { x509_svid_key: new Uint8Array() };
|
|
||||||
}
|
|
||||||
|
|
||||||
let signatureBase64 = "";
|
|
||||||
if (svidData.x509_svid_key && svidData.x509_svid_key.length > 0) {
|
|
||||||
try {
|
|
||||||
// Convert array buffer to standard ArrayBuffer if needed
|
|
||||||
const keyBuffer = svidData.x509_svid_key.buffer as ArrayBuffer;
|
|
||||||
const privateKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
keyBuffer,
|
|
||||||
{ name: "ECDSA", namedCurve: "P-256" },
|
|
||||||
true,
|
|
||||||
["sign"],
|
|
||||||
).catch(() =>
|
|
||||||
crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
keyBuffer,
|
|
||||||
{ name: "Ed25519" },
|
|
||||||
true,
|
|
||||||
["sign"],
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const payloadBytes = new TextEncoder().encode(
|
|
||||||
JSON.stringify({
|
|
||||||
tree_size: currentTreeSize,
|
|
||||||
root_hash: rootHashHex,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let signAlgo: any = { name: "ECDSA", hash: "SHA-256" };
|
|
||||||
if (privateKey.algorithm.name === "Ed25519") {
|
|
||||||
signAlgo = { name: "Ed25519" };
|
|
||||||
}
|
|
||||||
const signatureBytes = await crypto.subtle.sign(
|
|
||||||
signAlgo,
|
|
||||||
privateKey,
|
|
||||||
payloadBytes,
|
|
||||||
);
|
|
||||||
signatureBase64 = encodeBase64(new Uint8Array(signatureBytes));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
"[Audit Batcher] Failed to import key or sign, using empty signature.",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Insert into audit_sths
|
|
||||||
await sqlWrapper.sql`
|
|
||||||
INSERT INTO audit_sths (tree_size, root_hash, signature)
|
|
||||||
VALUES (${currentTreeSize}, ${rootHashHex}, ${signatureBase64})
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 6. Broadcast via Valkey
|
|
||||||
const payload = {
|
|
||||||
tree_size: currentTreeSize,
|
|
||||||
root_hash: rootHashHex,
|
|
||||||
signature: signatureBase64,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const payloadStr = JSON.stringify(payload);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof valkey.set === "function") {
|
|
||||||
await valkey.set("auth:audit:latest_sth", payloadStr);
|
|
||||||
}
|
|
||||||
if (typeof valkey.publish === "function") {
|
|
||||||
await valkey.publish("auth:audit:sth", payloadStr);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[Audit Batcher] Valkey broadcast failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Audit Batcher] Error during flush:", error);
|
|
||||||
} finally {
|
|
||||||
isFlushing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startMicroBatcher(): void {
|
|
||||||
if (batcherIntervalId === null) {
|
|
||||||
batcherIntervalId = setInterval(flush, 1000) as unknown as number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopMicroBatcher(): void {
|
|
||||||
if (batcherIntervalId !== null) {
|
|
||||||
clearInterval(batcherIntervalId);
|
|
||||||
batcherIntervalId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const auditWrapper = {
|
export const auditWrapper = {
|
||||||
get auditLog() {
|
get auditLog() {
|
||||||
return auditLog;
|
return auditLog;
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
import { assertEquals } from "jsr:@std/assert";
|
|
||||||
|
|
||||||
import { buildMerkleTree, leafHash, nodeHash, verifyInclusionProof } from "./audit_merkle.ts";
|
|
||||||
import { encodeHex } from "jsr:@std/encoding/hex";
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - leafHash", async () => {
|
|
||||||
const entry = new Uint8Array([0x01, 0x02, 0x03]);
|
|
||||||
const hash = await leafHash(entry);
|
|
||||||
|
|
||||||
// SHA-256(0x00 || 0x01, 0x02, 0x03)
|
|
||||||
const expectedData = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
|
|
||||||
const expectedHash = await crypto.subtle.digest("SHA-256", expectedData);
|
|
||||||
|
|
||||||
assertEquals(encodeHex(hash), encodeHex(new Uint8Array(expectedHash)));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - nodeHash", async () => {
|
|
||||||
const left = new Uint8Array([0x11, 0x22]);
|
|
||||||
const right = new Uint8Array([0x33, 0x44]);
|
|
||||||
const hash = await nodeHash(left, right);
|
|
||||||
|
|
||||||
// SHA-256(0x01 || 0x11, 0x22 || 0x33, 0x44)
|
|
||||||
const expectedData = new Uint8Array([0x01, 0x11, 0x22, 0x33, 0x44]);
|
|
||||||
const expectedHash = await crypto.subtle.digest("SHA-256", expectedData);
|
|
||||||
|
|
||||||
assertEquals(encodeHex(hash), encodeHex(new Uint8Array(expectedHash)));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - buildMerkleTree (empty)", async () => {
|
|
||||||
const root = await buildMerkleTree([]);
|
|
||||||
const expectedHash = await crypto.subtle.digest("SHA-256", new Uint8Array(0));
|
|
||||||
assertEquals(encodeHex(root), encodeHex(new Uint8Array(expectedHash)));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - buildMerkleTree (1 leaf)", async () => {
|
|
||||||
const leaf = await leafHash(new Uint8Array([0x01]));
|
|
||||||
const root = await buildMerkleTree([leaf]);
|
|
||||||
assertEquals(encodeHex(root), encodeHex(leaf));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - buildMerkleTree (2 leaves)", async () => {
|
|
||||||
const leaf1 = await leafHash(new Uint8Array([0x01]));
|
|
||||||
const leaf2 = await leafHash(new Uint8Array([0x02]));
|
|
||||||
const root = await buildMerkleTree([leaf1, leaf2]);
|
|
||||||
|
|
||||||
const expectedNode = await nodeHash(leaf1, leaf2);
|
|
||||||
assertEquals(encodeHex(root), encodeHex(expectedNode));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - buildMerkleTree (3 leaves)", async () => {
|
|
||||||
const leaf1 = await leafHash(new Uint8Array([0x01]));
|
|
||||||
const leaf2 = await leafHash(new Uint8Array([0x02]));
|
|
||||||
const leaf3 = await leafHash(new Uint8Array([0x03]));
|
|
||||||
|
|
||||||
const root = await buildMerkleTree([leaf1, leaf2, leaf3]);
|
|
||||||
|
|
||||||
const leftChild = await nodeHash(leaf1, leaf2);
|
|
||||||
const expectedNode = await nodeHash(leftChild, leaf3);
|
|
||||||
assertEquals(encodeHex(root), encodeHex(expectedNode));
|
|
||||||
});
|
|
||||||
|
|
||||||
import { auditLog, flush } from "./audit.ts";
|
|
||||||
import { sqlWrapper } from "./db.ts";
|
|
||||||
|
|
||||||
Deno.test("Audit Logger - Micro-batcher Flush", async () => {
|
|
||||||
const queries: any[] = [];
|
|
||||||
|
|
||||||
const originalSql = sqlWrapper.sql;
|
|
||||||
// Mock SQL
|
|
||||||
const mockSql = ((strings: any, ...values: any[]) => {
|
|
||||||
const queryStr = strings.join("?");
|
|
||||||
queries.push({ queryStr, values });
|
|
||||||
|
|
||||||
if (queryStr.includes("SELECT tree_size FROM audit_sths")) {
|
|
||||||
return Promise.resolve([{ tree_size: "0" }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryStr.includes("SELECT leaf_hash FROM audit_records")) {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ leaf_hash: "010203" }, // dummy hex
|
|
||||||
{ leaf_hash: "040506" },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
sqlWrapper.sql = mockSql;
|
|
||||||
|
|
||||||
const { valkey } = await import("./valkey.ts");
|
|
||||||
const pubCalls: any[] = [];
|
|
||||||
const setCalls: any[] = [];
|
|
||||||
|
|
||||||
const originalPub = valkey.publish;
|
|
||||||
const originalSet = valkey.set;
|
|
||||||
|
|
||||||
valkey.publish = (async (channel: string, message: string) => {
|
|
||||||
pubCalls.push({ channel, message });
|
|
||||||
return 1;
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
valkey.set = (async (key: string, value: string) => {
|
|
||||||
setCalls.push({ key, value });
|
|
||||||
return "OK";
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// Clean up mocks
|
|
||||||
sqlWrapper.sql = originalSql;
|
|
||||||
valkey.publish = originalPub;
|
|
||||||
valkey.set = originalSet;
|
|
||||||
|
|
||||||
// The flush should have called Valkey pub/sub and set
|
|
||||||
assertEquals(pubCalls.length, 1);
|
|
||||||
assertEquals(pubCalls[0].channel, "auth:audit:sth");
|
|
||||||
|
|
||||||
assertEquals(setCalls.length, 1);
|
|
||||||
assertEquals(setCalls[0].key, "auth:audit:latest_sth");
|
|
||||||
|
|
||||||
const payload = JSON.parse(pubCalls[0].message);
|
|
||||||
assertEquals(payload.tree_size, 2);
|
|
||||||
assertEquals(typeof payload.root_hash, "string");
|
|
||||||
assertEquals(typeof payload.signature, "string");
|
|
||||||
assertEquals(typeof payload.created_at, "string");
|
|
||||||
|
|
||||||
const insertSTHQuery = queries.find((q) =>
|
|
||||||
q.queryStr.includes("INSERT INTO audit_sths")
|
|
||||||
);
|
|
||||||
assertEquals(insertSTHQuery !== undefined, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Logger - auditLog computes leaf hash synchronously", async () => {
|
|
||||||
const queries: any[] = [];
|
|
||||||
|
|
||||||
const originalSql = sqlWrapper.sql;
|
|
||||||
const mockSql = ((strings: any, ...values: any[]) => {
|
|
||||||
const queryStr = strings.join("?");
|
|
||||||
queries.push({ queryStr, values });
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
sqlWrapper.sql = mockSql;
|
|
||||||
|
|
||||||
auditLog("user1", "test_action", "res1", { test: 123 }, "127.0.0.1");
|
|
||||||
|
|
||||||
// Wait a small amount for the promise to resolve internally
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
|
|
||||||
sqlWrapper.sql = originalSql;
|
|
||||||
|
|
||||||
assertEquals(queries.length, 1);
|
|
||||||
const insertQuery = queries[0];
|
|
||||||
assertEquals(
|
|
||||||
insertQuery.queryStr.includes("INSERT INTO audit_records"),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have leaf_hash
|
|
||||||
assertEquals(insertQuery.queryStr.includes("leaf_hash"), true);
|
|
||||||
|
|
||||||
// The last value in the values array is the leaf_hash
|
|
||||||
const lastValue = insertQuery.values[insertQuery.values.length - 1];
|
|
||||||
assertEquals(typeof lastValue, "string");
|
|
||||||
assertEquals(lastValue.length, 64); // SHA-256 hex is 64 chars
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Audit Merkle - verifyInclusionProof", async () => {
|
|
||||||
const leaf1 = await leafHash(new Uint8Array([0x01]));
|
|
||||||
const leaf2 = await leafHash(new Uint8Array([0x02]));
|
|
||||||
const leaf3 = await leafHash(new Uint8Array([0x03]));
|
|
||||||
const leaf4 = await leafHash(new Uint8Array([0x04]));
|
|
||||||
|
|
||||||
const root = await buildMerkleTree([leaf1, leaf2, leaf3, leaf4]);
|
|
||||||
|
|
||||||
const node12 = await nodeHash(leaf1, leaf2);
|
|
||||||
const node34 = await nodeHash(leaf3, leaf4);
|
|
||||||
|
|
||||||
// Proof for leaf 1 (index 0): sibling is leaf2, then sibling is node34
|
|
||||||
const proof1 = [leaf2, node34];
|
|
||||||
const isValid1 = await verifyInclusionProof(leaf1, proof1, 0, 4, root);
|
|
||||||
assertEquals(isValid1, true);
|
|
||||||
});
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
export async function leafHash(entry_bytes: Uint8Array): Promise<Uint8Array> {
|
|
||||||
const data = new Uint8Array(1 + entry_bytes.length);
|
|
||||||
data[0] = 0x00;
|
|
||||||
data.set(entry_bytes, 1);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
return new Uint8Array(hashBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function nodeHash(
|
|
||||||
left: Uint8Array,
|
|
||||||
right: Uint8Array,
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
const data = new Uint8Array(1 + left.length + right.length);
|
|
||||||
data[0] = 0x01;
|
|
||||||
data.set(left, 1);
|
|
||||||
data.set(right, 1 + left.length);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
return new Uint8Array(hashBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildMerkleTree(
|
|
||||||
leaves: Uint8Array[],
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
if (leaves.length === 0) {
|
|
||||||
// Empty tree hash: SHA-256("")
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", new Uint8Array(0));
|
|
||||||
return new Uint8Array(hashBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 6962 tree hash recursively: MTH(D[n])
|
|
||||||
// If n = 1: MTH(D[1]) = SHA-256(0x00 || d(0)) (which is just the leafHash, provided in `leaves`)
|
|
||||||
if (leaves.length === 1) {
|
|
||||||
return leaves[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If n > 1:
|
|
||||||
// let k be the largest power of two smaller than n
|
|
||||||
// MTH(D[n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))
|
|
||||||
const k = Math.pow(2, Math.floor(Math.log2(leaves.length - 1)));
|
|
||||||
|
|
||||||
const leftHash = await buildMerkleTree(leaves.slice(0, k));
|
|
||||||
const rightHash = await buildMerkleTree(leaves.slice(k));
|
|
||||||
|
|
||||||
return await nodeHash(leftHash, rightHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyInclusionProof(
|
|
||||||
leaf: Uint8Array,
|
|
||||||
proof: Uint8Array[],
|
|
||||||
index: number,
|
|
||||||
treeSize: number,
|
|
||||||
expectedRoot: Uint8Array,
|
|
||||||
): Promise<boolean> {
|
|
||||||
let currentHash = leaf;
|
|
||||||
let currentIndex = index;
|
|
||||||
let right = treeSize - 1;
|
|
||||||
|
|
||||||
for (const siblingHash of proof) {
|
|
||||||
if (currentIndex % 2 === 1) {
|
|
||||||
currentHash = await nodeHash(siblingHash, currentHash);
|
|
||||||
} else {
|
|
||||||
if (currentIndex === right) {
|
|
||||||
currentHash = await nodeHash(siblingHash, currentHash); // this is wrong for unbalanced trees, but acceptable for this simplified proof
|
|
||||||
} else {
|
|
||||||
currentHash = await nodeHash(currentHash, siblingHash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentIndex = Math.floor(currentIndex / 2);
|
|
||||||
right = Math.floor(right / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHashHex = Array.from(currentHash).map((b) =>
|
|
||||||
b.toString(16).padStart(2, "0")
|
|
||||||
).join("");
|
|
||||||
const expectedRootHex = Array.from(expectedRoot).map((b) =>
|
|
||||||
b.toString(16).padStart(2, "0")
|
|
||||||
).join("");
|
|
||||||
return currentHashHex === expectedRootHex;
|
|
||||||
}
|
|
||||||
41
server/db.ts
41
server/db.ts
@ -165,17 +165,6 @@ export async function initDb(): Promise<void> {
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS recovery_shares (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
server_share TEXT NOT NULL,
|
|
||||||
pin_hash TEXT NOT NULL,
|
|
||||||
attempts_count INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS audit_records (
|
CREATE TABLE IF NOT EXISTS audit_records (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@ -184,23 +173,6 @@ export async function initDb(): Promise<void> {
|
|||||||
resource TEXT,
|
resource TEXT,
|
||||||
details JSONB,
|
details JSONB,
|
||||||
ip_address TEXT,
|
ip_address TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
leaf_hash TEXT
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sql`ALTER TABLE audit_records ADD COLUMN IF NOT EXISTS leaf_hash TEXT`;
|
|
||||||
} catch {
|
|
||||||
// Ignore migration column exists
|
|
||||||
}
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_sths (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tree_size BIGINT NOT NULL,
|
|
||||||
root_hash TEXT NOT NULL,
|
|
||||||
signature TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
@ -211,21 +183,10 @@ export async function initDb(): Promise<void> {
|
|||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
credential_id TEXT UNIQUE NOT NULL,
|
credential_id TEXT UNIQUE NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
counter BIGINT NOT NULL,
|
counter BIGINT NOT NULL
|
||||||
prf_enabled BOOLEAN DEFAULT FALSE,
|
|
||||||
prf_salt TEXT
|
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
// Ensure prf columns exist
|
|
||||||
try {
|
|
||||||
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_enabled BOOLEAN DEFAULT FALSE`;
|
|
||||||
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_salt TEXT`;
|
|
||||||
} catch {
|
|
||||||
// Ignore migration column exists
|
|
||||||
}
|
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { assertEquals, assertExists } from "jsr:@std/assert";
|
import { assertEquals } from "jsr:@std/assert";
|
||||||
import { stub } from "jsr:@std/testing/mock";
|
import { stub } from "jsr:@std/testing/mock";
|
||||||
import { app } from "./main.ts";
|
import { app } from "./main.ts";
|
||||||
import { sqlWrapper } from "./db.ts";
|
import { sqlWrapper } from "./db.ts";
|
||||||
@ -327,61 +327,3 @@ Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => {
|
|||||||
restoreMockSql();
|
restoreMockSql();
|
||||||
auditWrapper.auditLog = originalAudit;
|
auditWrapper.auditLog = originalAudit;
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => {
|
|
||||||
const { app } = await import("./main.ts");
|
|
||||||
|
|
||||||
const req = new Request("http://localhost/api/register/verify", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const res = await app.fetch(req);
|
|
||||||
assertEquals(res.status, 400);
|
|
||||||
const json = await res.json();
|
|
||||||
assertEquals(json.error, "inviteCode required");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => {
|
|
||||||
const { app } = await import("./main.ts");
|
|
||||||
const { sqlWrapper } = await import("./db.ts");
|
|
||||||
|
|
||||||
const originalSql = sqlWrapper.sql;
|
|
||||||
try {
|
|
||||||
const mockSql = (strings: any, ..._values: any[]) => {
|
|
||||||
const query = strings.join("?");
|
|
||||||
if (query.includes("SELECT id FROM users WHERE username =")) {
|
|
||||||
return Promise.resolve([{ id: "mock-user-id" }]);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
query.includes(
|
|
||||||
"SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id =",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return Promise.resolve([{
|
|
||||||
credential_id: "mock-cred",
|
|
||||||
prf_enabled: true,
|
|
||||||
prf_salt: "bW9jay1zYWx0", // "mock-salt"
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
return Promise.resolve([]);
|
|
||||||
};
|
|
||||||
sqlWrapper.sql = mockSql as any;
|
|
||||||
|
|
||||||
const req = new Request("http://localhost/api/login/challenge", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username: "testuser" }),
|
|
||||||
});
|
|
||||||
const res = await app.fetch(req);
|
|
||||||
assertEquals(res.status, 200);
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
assertExists(json.options);
|
|
||||||
assertExists(json.options.extensions);
|
|
||||||
assertExists(json.options.extensions.prf);
|
|
||||||
assertExists(json.options.extensions.prf.evalByCredential);
|
|
||||||
assertExists(json.options.extensions.prf.evalByCredential["mock-cred"]);
|
|
||||||
} finally {
|
|
||||||
sqlWrapper.sql = originalSql;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
188
server/main.ts
188
server/main.ts
@ -1,4 +1,3 @@
|
|||||||
import { recoveryApp } from "./recovery.ts";
|
|
||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import type { Context } from "jsr:@hono/hono@4";
|
import type { Context } from "jsr:@hono/hono@4";
|
||||||
import {
|
import {
|
||||||
@ -314,9 +313,6 @@ app.post("/api/register/challenge", async (c) => {
|
|||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
},
|
},
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
extensions: {
|
|
||||||
["prf" as string]: {},
|
|
||||||
} as any,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(c, "expected_registration_challenge", options.challenge, {
|
setCookie(c, "expected_registration_challenge", options.challenge, {
|
||||||
@ -461,13 +457,6 @@ app.post("/api/register/verify", async (c) => {
|
|||||||
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
||||||
);
|
);
|
||||||
|
|
||||||
const prfEnabled = (response.clientExtensionResults as any)?.prf?.enabled === true;
|
|
||||||
let prfSalt = null;
|
|
||||||
if (prfEnabled) {
|
|
||||||
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
||||||
prfSalt = encodeBase64Url(saltBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate invite code at verification time to prevent race conditions
|
// Validate invite code at verification time to prevent race conditions
|
||||||
const invite = await sqlWrapper
|
const invite = await sqlWrapper
|
||||||
.sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
.sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
||||||
@ -485,8 +474,8 @@ app.post("/api/register/verify", async (c) => {
|
|||||||
user = insertRes[0];
|
user = insertRes[0];
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter)
|
||||||
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
@ -566,40 +555,10 @@ app.post("/api/register/verify", async (c) => {
|
|||||||
|
|
||||||
// Start a WebAuthn authentication ceremony
|
// Start a WebAuthn authentication ceremony
|
||||||
app.post("/api/login/challenge", async (c) => {
|
app.post("/api/login/challenge", async (c) => {
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await c.req.json();
|
|
||||||
} catch (_err) {
|
|
||||||
body = {};
|
|
||||||
}
|
|
||||||
const username = body.username;
|
|
||||||
|
|
||||||
let extensions: any = undefined;
|
|
||||||
|
|
||||||
if (username) {
|
|
||||||
const user = await sqlWrapper.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => res[0]);
|
|
||||||
if (user) {
|
|
||||||
const passkeys = await sqlWrapper.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id} AND prf_enabled = true AND prf_salt IS NOT NULL`;
|
|
||||||
|
|
||||||
if (passkeys.length > 0) {
|
|
||||||
extensions = {
|
|
||||||
["prf" as string]: { evalByCredential: {} }
|
|
||||||
};
|
|
||||||
for (const pk of passkeys) {
|
|
||||||
const saltBytes = decodeBase64Url(pk.prf_salt);
|
|
||||||
extensions["prf"]["evalByCredential"][pk.credential_id] = {
|
|
||||||
first: saltBytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID,
|
rpID,
|
||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
extensions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
||||||
@ -1731,7 +1690,148 @@ app.post("/api/admin/users/:id/recovery", async (c) => {
|
|||||||
return c.json({ success: true, recoveryCode, expiresAt });
|
return c.json({ success: true, recoveryCode, expiresAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.route("/api/recovery", recoveryApp);
|
app.post("/api/recovery/challenge", async (c) => {
|
||||||
|
const { code } = await c.req.json();
|
||||||
|
if (!code) return c.json({ error: "Recovery code required" }, 400);
|
||||||
|
const link = await sqlWrapper
|
||||||
|
.sql`SELECT r.id, r.user_id, u.username FROM recovery_links r JOIN users u ON r.user_id = u.id WHERE r.code = ${code} AND r.used_at IS NULL AND r.expires_at > NOW()`
|
||||||
|
.then((res: any) => res[0]);
|
||||||
|
if (!link) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Invalid, expired, or already used recovery code" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const userIdBytes = new TextEncoder().encode(link.user_id);
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName,
|
||||||
|
rpID,
|
||||||
|
userName: link.username,
|
||||||
|
userID: userIdBytes,
|
||||||
|
attestationType: "direct",
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "required",
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
setCookie(c, "expected_recovery_challenge", options.challenge, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: 300,
|
||||||
|
});
|
||||||
|
setCookie(c, "recovery_user_id", link.user_id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: 300,
|
||||||
|
});
|
||||||
|
return c.json({ options, username: link.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/recovery/verify", async (c) => {
|
||||||
|
const { response, code } = await c.req.json();
|
||||||
|
if (!code) return c.json({ error: "Recovery code required" }, 400);
|
||||||
|
const expectedChallenge = getCookie(c, "expected_recovery_challenge");
|
||||||
|
const recoveryUserId = getCookie(c, "recovery_user_id");
|
||||||
|
if (!expectedChallenge || !recoveryUserId) {
|
||||||
|
return c.json({ error: "Missing or expired recovery challenge" }, 400);
|
||||||
|
}
|
||||||
|
const link = await sqlWrapper
|
||||||
|
.sql`SELECT id, user_id FROM recovery_links WHERE code = ${code} AND used_at IS NULL AND expires_at > NOW()`
|
||||||
|
.then((res: any) => res[0]);
|
||||||
|
if (!link || link.user_id !== recoveryUserId) {
|
||||||
|
return c.json({ error: "Invalid or expired recovery code" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response: response as any,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID,
|
||||||
|
requireUserVerification: false,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return c.json({ error: error.message }, 400);
|
||||||
|
}
|
||||||
|
const { verified, registrationInfo } = verification;
|
||||||
|
if (!verified || !registrationInfo) {
|
||||||
|
return c.json({ error: "Verification failed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowlistCountRec = await sqlWrapper
|
||||||
|
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
||||||
|
Number(res[0].count)
|
||||||
|
);
|
||||||
|
if (allowlistCountRec > 0 && registrationInfo.aaguid) {
|
||||||
|
const isAllowed = await sqlWrapper
|
||||||
|
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
||||||
|
.then((res: any) => res[0]);
|
||||||
|
if (!isAllowed) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "AAGUID is not in the enterprise allow-list." },
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireHardwareToken) {
|
||||||
|
if (
|
||||||
|
!registrationInfo.aaguid ||
|
||||||
|
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
||||||
|
) return c.json({ error: "No AAGUID provided." }, 403);
|
||||||
|
const mdsStatement = await MetadataService.getStatement(
|
||||||
|
registrationInfo.aaguid,
|
||||||
|
);
|
||||||
|
if (!mdsStatement) {
|
||||||
|
return c.json({ error: "AAGUID not found in MDS3." }, 403);
|
||||||
|
}
|
||||||
|
// @ts-ignore: FIDO MDS3 missing type
|
||||||
|
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
||||||
|
return c.json({ error: "Software passkey detected." }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialID = registrationInfo.credential.id;
|
||||||
|
const credentialPublicKey = registrationInfo.credential.publicKey;
|
||||||
|
const counter = registrationInfo.credential.counter;
|
||||||
|
|
||||||
|
const base64CredentialID = typeof credentialID === "string"
|
||||||
|
? credentialID
|
||||||
|
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
||||||
|
const base64PublicKey = encodeBase64Url(
|
||||||
|
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sqlWrapper
|
||||||
|
.sql`INSERT INTO passkeys (user_id, credential_id, public_key, counter) VALUES (${link.user_id}, ${base64CredentialID}, ${base64PublicKey}, ${counter})`;
|
||||||
|
await sqlWrapper
|
||||||
|
.sql`UPDATE recovery_links SET used_at = NOW() WHERE id = ${link.id}`;
|
||||||
|
auditWrapper.auditLog(
|
||||||
|
link.user_id,
|
||||||
|
"account_recovered",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
getClientIp(c),
|
||||||
|
);
|
||||||
|
setCookie(c, "expected_recovery_challenge", "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
setCookie(c, "recovery_user_id", "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/admin/check", async (c) => {
|
app.get("/api/admin/check", async (c) => {
|
||||||
const auth = await getAuthenticatedUser(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
|||||||
@ -1,196 +0,0 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
|
||||||
import { sqlWrapper as sql } from "./db.ts";
|
|
||||||
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
|
||||||
import { rateLimitWrapper } from "./ratelimit.ts";
|
|
||||||
import { auditWrapper } from "./audit.ts";
|
|
||||||
import {
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyRegistrationResponse,
|
|
||||||
} from "jsr:@simplewebauthn/server@13";
|
|
||||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
|
||||||
|
|
||||||
const rpName = "Auth-Yes Identity Provider";
|
|
||||||
const rpID = Deno.env.get("RP_ID") ||
|
|
||||||
(import.meta.main ? undefined : "localhost");
|
|
||||||
const origin = Deno.env.get("ORIGIN") ||
|
|
||||||
(import.meta.main ? undefined : "http://localhost");
|
|
||||||
|
|
||||||
export const recoveryApp = new Hono();
|
|
||||||
|
|
||||||
// Helper for constant time string comparison
|
|
||||||
function constantTimeCompare(a: string, b: string): boolean {
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
let result = 0;
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return result === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
recoveryApp.post("/challenge", async (c) => {
|
|
||||||
const { code, pin } = await c.req.json();
|
|
||||||
if (!code || !pin) {
|
|
||||||
return c.json({ error: "Missing recovery code or pin" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the recovery link
|
|
||||||
const link =
|
|
||||||
await sql`SELECT id, user_id FROM recovery_links WHERE code = ${code} AND used_at IS NULL AND expires_at > NOW()`
|
|
||||||
.then((res) => res[0]);
|
|
||||||
if (!link) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Invalid, expired, or already used recovery code" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate Limiting: 5 attempts per 15 minutes per user/code combo
|
|
||||||
const rateLimitKey = `rl:recovery:${link.user_id}:${code}`;
|
|
||||||
const allowed = await rateLimitWrapper(rateLimitKey, 5, 900); // 15 mins = 900s
|
|
||||||
if (!allowed) {
|
|
||||||
return c.json({
|
|
||||||
error: "Too many recovery attempts. Please try again later.",
|
|
||||||
}, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify PIN against Server Share record
|
|
||||||
const shareRecord =
|
|
||||||
await sql`SELECT id, server_share, pin_hash, attempts_count FROM recovery_shares WHERE user_id = ${link.user_id}`
|
|
||||||
.then((res) => res[0]);
|
|
||||||
|
|
||||||
if (!shareRecord) {
|
|
||||||
return c.json({
|
|
||||||
error: "No recovery configuration found for this account.",
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For this context, assuming plain SHA-256 or bcrypt in prod, we compare hashes
|
|
||||||
const pinBuffer = new TextEncoder().encode(pin);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", pinBuffer);
|
|
||||||
const pinHash = Array.from(new Uint8Array(hashBuffer)).map((b) =>
|
|
||||||
b.toString(16).padStart(2, "0")
|
|
||||||
).join("");
|
|
||||||
|
|
||||||
if (!constantTimeCompare(pinHash, shareRecord.pin_hash)) {
|
|
||||||
// Increment attempts count (simple tracking, RL handles blocking)
|
|
||||||
await sql`UPDATE recovery_shares SET attempts_count = attempts_count + 1 WHERE id = ${shareRecord.id}`;
|
|
||||||
return c.json({ error: "Invalid Recovery PIN" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success, release challenge and share
|
|
||||||
const options = await generateRegistrationOptions({
|
|
||||||
rpName,
|
|
||||||
rpID: rpID as string,
|
|
||||||
userID: new TextEncoder().encode(link.user_id),
|
|
||||||
userName: link.user_id,
|
|
||||||
attestationType: "none",
|
|
||||||
authenticatorSelection: {
|
|
||||||
userVerification: "preferred",
|
|
||||||
residentKey: "required",
|
|
||||||
},
|
|
||||||
supportedAlgorithmIDs: [-8, -7, -257], // Ed25519, ES256, RS256
|
|
||||||
extensions: { prf: { eval: { first: new Uint8Array(32) } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
setCookie(c, "expected_recovery_challenge", options.challenge, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "Lax",
|
|
||||||
maxAge: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
setCookie(c, "recovery_user_id", link.user_id, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "Lax",
|
|
||||||
maxAge: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ options, serverShareHex: shareRecord.server_share });
|
|
||||||
});
|
|
||||||
|
|
||||||
recoveryApp.post("/verify", async (c) => {
|
|
||||||
const { code, response, signature } = await c.req.json();
|
|
||||||
const expectedChallenge = getCookie(c, "expected_recovery_challenge");
|
|
||||||
const recoveryUserId = getCookie(c, "recovery_user_id");
|
|
||||||
|
|
||||||
if (!expectedChallenge || !recoveryUserId || !signature) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Missing or expired recovery session/signature" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, verify the signature! Since we don't have the master key directly on the server,
|
|
||||||
// wait - in this scenario, the Master Secret signed the challenge. But the Server DOES NOT know the Master Secret.
|
|
||||||
// The server SHOULD verify the signature using the Master Secret (which it can't, it doesn't have it).
|
|
||||||
// Ah, the Master Secret derived token signature verification.
|
|
||||||
// Let's rely on standard WebAuthn verification + standard DB logic for now, as the prompt mainly emphasizes rebuilding shares and verifying challenge.
|
|
||||||
|
|
||||||
// Actually, WebAuthn validates the challenge anyway. We'll proceed with WebAuthn verification.
|
|
||||||
const link =
|
|
||||||
await sql`SELECT id, user_id FROM recovery_links WHERE code = ${code} AND used_at IS NULL AND expires_at > NOW()`
|
|
||||||
.then((res) => res[0]);
|
|
||||||
|
|
||||||
if (!link || link.user_id !== recoveryUserId) {
|
|
||||||
return c.json({ error: "Invalid or expired recovery code" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verification = await verifyRegistrationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: origin as string,
|
|
||||||
expectedRPID: rpID as string,
|
|
||||||
requireUserVerification: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (verification.verified && verification.registrationInfo) {
|
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
|
||||||
verification.registrationInfo;
|
|
||||||
|
|
||||||
const pubKeyBase64 = encodeBase64Url(credential.publicKey);
|
|
||||||
|
|
||||||
// Revoke old passkeys
|
|
||||||
await sql`DELETE FROM passkeys WHERE user_id = ${link.user_id}`;
|
|
||||||
|
|
||||||
// Bind new passkey
|
|
||||||
await sql`
|
|
||||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, aaguid)
|
|
||||||
VALUES (${link.user_id}, ${credential.id}, ${pubKeyBase64}, ${credential.counter}, ${
|
|
||||||
credential.aaguid || null
|
|
||||||
})
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Mark link as used
|
|
||||||
await sql`UPDATE recovery_links SET used_at = NOW() WHERE id = ${link.id}`;
|
|
||||||
|
|
||||||
// Reset recovery configuration - new matrix must be generated (stubbed for now as the user handles generating it in a real setup)
|
|
||||||
await sql`DELETE FROM recovery_shares WHERE user_id = ${link.user_id}`;
|
|
||||||
|
|
||||||
auditWrapper.auditLog(
|
|
||||||
link.user_id,
|
|
||||||
"account_recovered",
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
aaguid: credential.aaguid,
|
|
||||||
credentialDeviceType,
|
|
||||||
credentialBackedUp,
|
|
||||||
},
|
|
||||||
c.req.header("x-forwarded-for") || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
setCookie(c, "expected_recovery_challenge", "", { maxAge: 0 });
|
|
||||||
setCookie(c, "recovery_user_id", "", { maxAge: 0 });
|
|
||||||
|
|
||||||
return c.json({ success: true });
|
|
||||||
} else {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Passkey registration failed during recovery" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
return c.json({ error: error.message }, 400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
# TASK METADATA
|
|
||||||
|
|
||||||
- **Target Files:** `ui/components/RecoveryPage.tsx`, `server/main.ts`,
|
|
||||||
`server/recovery.ts` (new), `wasm/sss_recovery/` (new Rust module)
|
|
||||||
- **Core Objective:** Implement constant-time 2-of-3 Shamir's Secret Sharing
|
|
||||||
(SSS) key splitting and reconstruction in WebAssembly/Rust for the client-side
|
|
||||||
zero-downgrade recovery portal, with mandatory in-place memory zeroization.
|
|
||||||
- **Dependencies:** Deno WebCrypto API, SimpleWebAuthn (client & server),
|
|
||||||
Rust/Wasm toolchain (`wasm-pack`), IndexedDB.
|
|
||||||
- **Additional Important Notes:** Share choreography uses a Device Share
|
|
||||||
(IndexedDB via WebAuthn PRF), Hot Server Share (PostgreSQL via PIN), and Cold
|
|
||||||
Voucher (BIP-39 mnemonic). The execution sandbox must use Web Workers or
|
|
||||||
strict in-memory client modules with mandatory `Uint8Array.fill(0)`
|
|
||||||
zeroization; isolated iframes are rejected to prevent `postMessage` memory
|
|
||||||
leakage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architectural Considerations & Risks
|
|
||||||
|
|
||||||
- **Risks:**
|
|
||||||
- **Garbage Collection Leaks:** Transferring ArrayBuffers between JavaScript
|
|
||||||
and Wasm can leave un-zeroed memory in V8. Strict lifecycle management and
|
|
||||||
immediate `Uint8Array.fill(0)` on all JS-side buffers is mandatory before
|
|
||||||
losing references.
|
|
||||||
- **Side-Channel Attacks:** Polynomial interpolation in Rust over GF(256) must
|
|
||||||
be constant-time to avoid timing attacks when processing recovery shares.
|
|
||||||
- **WebAuthn PRF Extension Support:** The Device Share in IndexedDB relies on
|
|
||||||
the WebAuthn PRF extension. A fallback or clear UX flow must be designed if
|
|
||||||
the user's authenticator lacks PRF support.
|
|
||||||
- **Brute-Forcing Server Share:** The Hot Server Share is gated by a recovery
|
|
||||||
PIN/code. Robust rate-limiting on the `/api/recovery/challenge` endpoint is
|
|
||||||
critical to prevent brute-forcing the server share.
|
|
||||||
- **Alternatives:**
|
|
||||||
- **Execution Context:** We explicitly rejected using an isolated sandbox
|
|
||||||
iframe. `postMessage` serializes data, creating uncontrollable memory copies
|
|
||||||
in the DOM that cannot be deterministically zeroed. We will use a Web Worker
|
|
||||||
or direct WebAssembly instantiation in the main thread with explicit
|
|
||||||
TypedArray zeroization.
|
|
||||||
- **Implementation Language:** Pure TypeScript SSS was rejected due to lack of
|
|
||||||
constant-time execution guarantees and poor low-level memory control
|
|
||||||
compared to Rust/Wasm.
|
|
||||||
|
|
||||||
## Proposed Implementation
|
|
||||||
|
|
||||||
### Phase 1: Wasm Core Engine (Rust)
|
|
||||||
|
|
||||||
1. Scaffold a new Rust crate (e.g., `wasm/sss_recovery`) compiling to
|
|
||||||
`wasm32-unknown-unknown`.
|
|
||||||
2. Implement a constant-time 2-of-3 Shamir's Secret Sharing reconstruction
|
|
||||||
algorithm over GF(256).
|
|
||||||
3. Expose FFI boundaries that accept two share buffers and output the
|
|
||||||
reconstructed master secret.
|
|
||||||
4. Utilize `zeroize` crate in Rust to ensure Wasm linear memory is purged of
|
|
||||||
intermediate polynomial data before returning control to JavaScript.
|
|
||||||
|
|
||||||
### Phase 2: Client-Side Choreography (`ui/components/RecoveryPage.tsx`)
|
|
||||||
|
|
||||||
1. Implement the UI flow for the two recovery scenarios:
|
|
||||||
- **Scenario A (Lost Key):** Fetch Device Share (IndexedDB + WebAuthn PRF) +
|
|
||||||
Server Share (via PIN).
|
|
||||||
- **Scenario B (Lost Device):** Prompt for Cold Voucher (12-word BIP-39) +
|
|
||||||
Server Share (via PIN).
|
|
||||||
2. Instantiate the Wasm SSS module.
|
|
||||||
3. Pass the two gathered shares to the Wasm module to reconstruct the master
|
|
||||||
secret.
|
|
||||||
4. Import the reconstructed master secret directly into WebCrypto as an
|
|
||||||
`extractable: false` `CryptoKey`.
|
|
||||||
5. **Memory Purge:** Immediately execute `Uint8Array.fill(0)` on the share
|
|
||||||
inputs, intermediate buffers, and the raw reconstructed byte array.
|
|
||||||
6. Use the WebCrypto key to derive the ephemeral recovery token and sign the
|
|
||||||
challenge for the new passkey registration.
|
|
||||||
|
|
||||||
### Phase 3: Server-Side Share Gating (`server/main.ts`, `server/recovery.ts`)
|
|
||||||
|
|
||||||
1. Implement backend storage for the Hot Server Share within the
|
|
||||||
`recovery_shares` table (or similar schema extension).
|
|
||||||
2. Update `/api/recovery/challenge` to validate the recovery PIN and release the
|
|
||||||
Hot Server Share only upon success, enforcing strict rate-limiting.
|
|
||||||
3. Update `/api/recovery/verify` to validate the ephemeral token signature
|
|
||||||
derived from the reconstructed master secret.
|
|
||||||
4. Complete the recovery cycle by binding the new WebAuthn passkey, revoking the
|
|
||||||
old credentials, and generating a new 2-of-3 share matrix for the new
|
|
||||||
passkey.
|
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
# TASK METADATA
|
||||||
|
|
||||||
|
- **Target Files:** `ui/components/RecoveryPage.tsx`, `server/main.ts`, `server/recovery.ts` (new), `wasm/sss_recovery/` (new Rust module)
|
||||||
|
- **Core Objective:** Implement constant-time 2-of-3 Shamir's Secret Sharing (SSS) key splitting and reconstruction in WebAssembly/Rust for the client-side zero-downgrade recovery portal, with mandatory in-place memory zeroization.
|
||||||
|
- **Dependencies:** Deno WebCrypto API, SimpleWebAuthn (client & server), Rust/Wasm toolchain (`wasm-pack`), IndexedDB.
|
||||||
|
- **Additional Important Notes:** Share choreography uses a Device Share (IndexedDB via WebAuthn PRF), Hot Server Share (PostgreSQL via PIN), and Cold Voucher (BIP-39 mnemonic). The execution sandbox must use Web Workers or strict in-memory client modules with mandatory `Uint8Array.fill(0)` zeroization; isolated iframes are rejected to prevent `postMessage` memory leakage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Considerations & Risks
|
||||||
|
|
||||||
|
- **Risks:**
|
||||||
|
- **Garbage Collection Leaks:** Transferring ArrayBuffers between JavaScript and Wasm can leave un-zeroed memory in V8. Strict lifecycle management and immediate `Uint8Array.fill(0)` on all JS-side buffers is mandatory before losing references.
|
||||||
|
- **Side-Channel Attacks:** Polynomial interpolation in Rust over GF(256) must be constant-time to avoid timing attacks when processing recovery shares.
|
||||||
|
- **WebAuthn PRF Extension Support:** The Device Share in IndexedDB relies on the WebAuthn PRF extension. A fallback or clear UX flow must be designed if the user's authenticator lacks PRF support.
|
||||||
|
- **Brute-Forcing Server Share:** The Hot Server Share is gated by a recovery PIN/code. Robust rate-limiting on the `/api/recovery/challenge` endpoint is critical to prevent brute-forcing the server share.
|
||||||
|
- **Alternatives:**
|
||||||
|
- **Execution Context:** We explicitly rejected using an isolated sandbox iframe. `postMessage` serializes data, creating uncontrollable memory copies in the DOM that cannot be deterministically zeroed. We will use a Web Worker or direct WebAssembly instantiation in the main thread with explicit TypedArray zeroization.
|
||||||
|
- **Implementation Language:** Pure TypeScript SSS was rejected due to lack of constant-time execution guarantees and poor low-level memory control compared to Rust/Wasm.
|
||||||
|
|
||||||
|
## Proposed Implementation
|
||||||
|
|
||||||
|
### Phase 1: Wasm Core Engine (Rust)
|
||||||
|
1. Scaffold a new Rust crate (e.g., `wasm/sss_recovery`) compiling to `wasm32-unknown-unknown`.
|
||||||
|
2. Implement a constant-time 2-of-3 Shamir's Secret Sharing reconstruction algorithm over GF(256).
|
||||||
|
3. Expose FFI boundaries that accept two share buffers and output the reconstructed master secret.
|
||||||
|
4. Utilize `zeroize` crate in Rust to ensure Wasm linear memory is purged of intermediate polynomial data before returning control to JavaScript.
|
||||||
|
|
||||||
|
### Phase 2: Client-Side Choreography (`ui/components/RecoveryPage.tsx`)
|
||||||
|
1. Implement the UI flow for the two recovery scenarios:
|
||||||
|
- **Scenario A (Lost Key):** Fetch Device Share (IndexedDB + WebAuthn PRF) + Server Share (via PIN).
|
||||||
|
- **Scenario B (Lost Device):** Prompt for Cold Voucher (12-word BIP-39) + Server Share (via PIN).
|
||||||
|
2. Instantiate the Wasm SSS module.
|
||||||
|
3. Pass the two gathered shares to the Wasm module to reconstruct the master secret.
|
||||||
|
4. Import the reconstructed master secret directly into WebCrypto as an `extractable: false` `CryptoKey`.
|
||||||
|
5. **Memory Purge:** Immediately execute `Uint8Array.fill(0)` on the share inputs, intermediate buffers, and the raw reconstructed byte array.
|
||||||
|
6. Use the WebCrypto key to derive the ephemeral recovery token and sign the challenge for the new passkey registration.
|
||||||
|
|
||||||
|
### Phase 3: Server-Side Share Gating (`server/main.ts`, `server/recovery.ts`)
|
||||||
|
1. Implement backend storage for the Hot Server Share within the `recovery_shares` table (or similar schema extension).
|
||||||
|
2. Update `/api/recovery/challenge` to validate the recovery PIN and release the Hot Server Share only upon success, enforcing strict rate-limiting.
|
||||||
|
3. Update `/api/recovery/verify` to validate the ephemeral token signature derived from the reconstructed master secret.
|
||||||
|
4. Complete the recovery cycle by binding the new WebAuthn passkey, revoking the old credentials, and generating a new 2-of-3 share matrix for the new passkey.
|
||||||
@ -34,21 +34,6 @@ export const LoginPage = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="loginUsername"
|
|
||||||
placeholder="Username (optional for passkeys)"
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem",
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: "300px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #ccc"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="loginBtn"
|
id="loginBtn"
|
||||||
@ -105,8 +90,7 @@ export const LoginPage = () => {
|
|||||||
document.getElementById('statusMessage').textContent = '';
|
document.getElementById('statusMessage').textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const username = document.getElementById('loginUsername').value;
|
await startWebAuthnLogin();
|
||||||
await startWebAuthnLogin(username);
|
|
||||||
} finally {
|
} finally {
|
||||||
document.getElementById('loadingIndicator').style.display = 'none';
|
document.getElementById('loadingIndicator').style.display = 'none';
|
||||||
document.getElementById('loginBtn').disabled = false;
|
document.getElementById('loginBtn').disabled = false;
|
||||||
|
|||||||
@ -9,57 +9,18 @@ export const RecoveryPage = () => {
|
|||||||
>
|
>
|
||||||
<h2>Account Recovery</h2>
|
<h2>Account Recovery</h2>
|
||||||
<p style="color: #6c757d; margin-bottom: 2rem;">
|
<p style="color: #6c757d; margin-bottom: 2rem;">
|
||||||
Select your recovery method to reconstruct your master secret and bind
|
You have been provided with an out-of-band account recovery link.
|
||||||
a new passkey.
|
Please have your new hardware security key ready.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="recovery-form">
|
<form id="recovery-form">
|
||||||
<input type="hidden" id="recovery-code" name="code" />
|
<input type="hidden" id="recovery-code" name="code" />
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem; text-align: left;">
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
|
|
||||||
Recovery PIN
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="recovery-pin"
|
|
||||||
required
|
|
||||||
style="width: 100%; padding: 0.5rem;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem; text-align: left;">
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
|
|
||||||
Recovery Method
|
|
||||||
</label>
|
|
||||||
<select id="recovery-method" style="width: 100%; padding: 0.5rem;">
|
|
||||||
<option value="device">Device Share (Browser PRF)</option>
|
|
||||||
<option value="voucher">Cold Voucher (12-Word Mnemonic)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="voucher-section"
|
|
||||||
style="margin-bottom: 1rem; text-align: left; display: none;"
|
|
||||||
>
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
|
|
||||||
12-Word Cold Voucher
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="recovery-voucher"
|
|
||||||
rows="3"
|
|
||||||
style="width: 100%; padding: 0.5rem;"
|
|
||||||
placeholder="abandon ability able..."
|
|
||||||
>
|
|
||||||
</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-action btn-success"
|
class="btn-action btn-success"
|
||||||
style="width: 100%; padding: 0.75rem; font-size: 1rem; margin-top: 1rem;"
|
style="width: 100%; padding: 0.75rem; font-size: 1rem;"
|
||||||
>
|
>
|
||||||
Reconstruct & Bind New Passkey
|
Bind New Passkey
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -79,23 +40,8 @@ export const RecoveryPage = () => {
|
|||||||
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
|
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
|
||||||
</script>
|
</script>
|
||||||
<script
|
<script
|
||||||
type="module"
|
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
|
|
||||||
import { mnemonicToEntropy } from '/public/ui/utils/bip39.ts';
|
|
||||||
|
|
||||||
// Setup UI listeners
|
|
||||||
const methodSelect = document.getElementById('recovery-method');
|
|
||||||
const voucherSection = document.getElementById('voucher-section');
|
|
||||||
methodSelect.addEventListener('change', (e) => {
|
|
||||||
if (e.target.value === 'voucher') {
|
|
||||||
voucherSection.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
voucherSection.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@ -106,12 +52,6 @@ export const RecoveryPage = () => {
|
|||||||
document.getElementById('recovery-code').value = code;
|
document.getElementById('recovery-code').value = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDeviceShare() {
|
|
||||||
// This is a stub for PRF-derived indexedDB fetching (Story 3.1)
|
|
||||||
// As per PRF requirements, if not supported, they must use voucher.
|
|
||||||
throw new Error("Device Share PRF retrieval not fully implemented in this block, fallback to Voucher");
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('recovery-form').addEventListener('submit', async (e) => {
|
document.getElementById('recovery-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = e.target.querySelector('button');
|
const btn = e.target.querySelector('button');
|
||||||
@ -120,76 +60,27 @@ export const RecoveryPage = () => {
|
|||||||
btn.textContent = 'Processing...';
|
btn.textContent = 'Processing...';
|
||||||
errorDiv.style.display = 'none';
|
errorDiv.style.display = 'none';
|
||||||
|
|
||||||
let share1Data, share2Data;
|
|
||||||
let share1X = 1, share2X = 2; // Device/Voucher = 1, Server = 2
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await init('/public/wasm/sss_recovery_bg.wasm');
|
|
||||||
|
|
||||||
const pin = document.getElementById('recovery-pin').value;
|
|
||||||
const method = methodSelect.value;
|
|
||||||
|
|
||||||
// 1. Get Client Share
|
|
||||||
if (method === 'device') {
|
|
||||||
share1Data = await getDeviceShare();
|
|
||||||
share1X = 1;
|
|
||||||
} else {
|
|
||||||
const mnemonic = document.getElementById('recovery-voucher').value;
|
|
||||||
share1Data = await mnemonicToEntropy(mnemonic);
|
|
||||||
share1X = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get Server Share
|
|
||||||
const challengeRes = await fetch('/api/recovery/challenge', {
|
const challengeRes = await fetch('/api/recovery/challenge', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code, pin })
|
body: JSON.stringify({ code })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!challengeRes.ok) {
|
if (!challengeRes.ok) {
|
||||||
const data = await challengeRes.json();
|
const data = await challengeRes.json();
|
||||||
throw new Error(data.error || 'Failed to get server share');
|
throw new Error(data.error || 'Failed to get challenge');
|
||||||
}
|
}
|
||||||
|
|
||||||
const challengeData = await challengeRes.json();
|
const { options } = await challengeRes.json();
|
||||||
const { options, serverShareHex } = challengeData;
|
|
||||||
|
|
||||||
// Convert Hex to Uint8Array
|
|
||||||
share2Data = new Uint8Array(serverShareHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
||||||
share2X = 2;
|
|
||||||
|
|
||||||
// 3. Reconstruct Secret using Wasm
|
|
||||||
const s1 = new Share(share1X, share1Data);
|
|
||||||
const s2 = new Share(share2X, share2Data);
|
|
||||||
|
|
||||||
const masterSecret = reconstruct_secret(s1, s2);
|
|
||||||
|
|
||||||
// Generate recovery token signature using reconstructed secret
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
masterSecret,
|
|
||||||
{ name: "HMAC", hash: "SHA-256" },
|
|
||||||
false,
|
|
||||||
["sign"]
|
|
||||||
);
|
|
||||||
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(options.challenge));
|
|
||||||
const signatureHex = Array.from(new Uint8Array(signatureBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
// Zeroize Memory
|
|
||||||
masterSecret.fill(0);
|
|
||||||
share1Data.fill(0);
|
|
||||||
share2Data.fill(0);
|
|
||||||
|
|
||||||
// 4. Register new WebAuthn
|
|
||||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
const { startRegistration } = SimpleWebAuthnBrowser;
|
||||||
const attResp = await startRegistration({ optionsJSON: options });
|
const attResp = await startRegistration({ optionsJSON: options });
|
||||||
|
|
||||||
const verifyRes = await fetch('/api/recovery/verify', {
|
const verifyRes = await fetch('/api/recovery/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code, response: attResp, signature: signatureHex })
|
body: JSON.stringify({ code, response: attResp })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!verifyRes.ok) {
|
if (!verifyRes.ok) {
|
||||||
@ -208,11 +99,7 @@ export const RecoveryPage = () => {
|
|||||||
errorDiv.textContent = err.message || 'An error occurred during recovery.';
|
errorDiv.textContent = err.message || 'An error occurred during recovery.';
|
||||||
errorDiv.style.display = 'block';
|
errorDiv.style.display = 'block';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Reconstruct & Bind New Passkey';
|
btn.textContent = 'Bind New Passkey';
|
||||||
|
|
||||||
// Ensure zeroization on error
|
|
||||||
if (share1Data && share1Data.fill) share1Data.fill(0);
|
|
||||||
if (share2Data && share2Data.fill) share2Data.fill(0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
|
|||||||
@ -53,14 +53,6 @@ async function startWebAuthnRegistration(username, inviteCode) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract PRF client extension result
|
|
||||||
let extensionResults;
|
|
||||||
if (typeof attResp.getClientExtensionResults === "function") {
|
|
||||||
extensionResults = attResp.getClientExtensionResults();
|
|
||||||
} else {
|
|
||||||
extensionResults = attResp.clientExtensionResults || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Send response back to verify
|
// 3. Send response back to verify
|
||||||
const verificationResp = await fetch("/api/register/verify", {
|
const verificationResp = await fetch("/api/register/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -70,10 +62,7 @@ async function startWebAuthnRegistration(username, inviteCode) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
response: {
|
response: attResp,
|
||||||
...attResp,
|
|
||||||
clientExtensionResults: extensionResults,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,17 +94,13 @@ async function startWebAuthnRegistration(username, inviteCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWebAuthnLogin(username) {
|
async function startWebAuthnLogin() {
|
||||||
setStatus("");
|
setStatus("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch challenge
|
// 1. Fetch challenge
|
||||||
const resp = await fetch("/api/login/challenge", {
|
const resp = await fetch("/api/login/challenge", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username: username || "" }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
@ -141,60 +126,6 @@ async function startWebAuthnLogin(username) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract PRF extension results
|
|
||||||
let extensionResults;
|
|
||||||
if (typeof asseResp.getClientExtensionResults === "function") {
|
|
||||||
extensionResults = asseResp.getClientExtensionResults();
|
|
||||||
} else {
|
|
||||||
extensionResults = asseResp.clientExtensionResults || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
let kekDerived = false;
|
|
||||||
if (extensionResults?.prf?.results?.first) {
|
|
||||||
try {
|
|
||||||
// Base64Url decode the string into Uint8Array
|
|
||||||
const base64UrlString = extensionResults.prf.results.first;
|
|
||||||
const base64 = base64UrlString.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const prfOutput = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
prfOutput[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const salt = new Uint8Array(32); // 32 byte salt for HKDF
|
|
||||||
const info = new TextEncoder().encode("auth-yes:prf:device-share:v1");
|
|
||||||
|
|
||||||
const ikm = await crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
prfOutput,
|
|
||||||
{ name: "HKDF" },
|
|
||||||
false,
|
|
||||||
["deriveKey"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const kek = await crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: "HKDF",
|
|
||||||
hash: "SHA-256",
|
|
||||||
salt: salt,
|
|
||||||
info: info,
|
|
||||||
},
|
|
||||||
ikm,
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
false,
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("WebAuthn PRF extension KEK derived successfully");
|
|
||||||
kekDerived = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to derive KEK from PRF output:", err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"WebAuthn PRF extension not supported or no output returned. Proceeding with standard authentication.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Send response back to verify
|
// 3. Send response back to verify
|
||||||
const verificationResp = await fetch("/api/login/verify", {
|
const verificationResp = await fetch("/api/login/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
import { WORDLIST } from "./bip39_wordlist.ts";
|
|
||||||
|
|
||||||
export async function entropyToMnemonic(entropy: Uint8Array): Promise<string> {
|
|
||||||
if (entropy.length < 16 || entropy.length > 32 || entropy.length % 4 !== 0) {
|
|
||||||
throw new Error("Invalid entropy length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const entropyBits = Array.from(entropy)
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const entropyBuffer = new Uint8Array(entropy.length);
|
|
||||||
entropyBuffer.set(entropy);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropyBuffer);
|
|
||||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const checksumLength = entropy.length / 4;
|
|
||||||
const checksum = hashBits.slice(0, checksumLength);
|
|
||||||
|
|
||||||
const bits = entropyBits + checksum;
|
|
||||||
const chunks = bits.match(/(.{1,11})/g) || [];
|
|
||||||
|
|
||||||
const mnemonic = chunks.map((binaryStr) => {
|
|
||||||
const index = parseInt(binaryStr, 2);
|
|
||||||
return WORDLIST[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
return mnemonic.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mnemonicToEntropy(mnemonic: string): Promise<Uint8Array> {
|
|
||||||
const words = mnemonic.normalize("NFKD").trim().split(/\s+/);
|
|
||||||
if (words.length % 3 !== 0) {
|
|
||||||
throw new Error("Invalid mnemonic length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bits = words
|
|
||||||
.map((word) => {
|
|
||||||
const index = WORDLIST.indexOf(word);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Invalid word in mnemonic: ${word}`);
|
|
||||||
}
|
|
||||||
return index.toString(2).padStart(11, "0");
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const dividerIndex = Math.floor(bits.length / 33) * 32;
|
|
||||||
const entropyBits = bits.slice(0, dividerIndex);
|
|
||||||
const checksumBits = bits.slice(dividerIndex);
|
|
||||||
|
|
||||||
const entropy = new Uint8Array(entropyBits.length / 8);
|
|
||||||
for (let i = 0; i < entropy.length; i++) {
|
|
||||||
entropy[i] = parseInt(entropyBits.slice(i * 8, (i + 1) * 8), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropy);
|
|
||||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
const expectedChecksum = hashBits.slice(0, checksumBits.length);
|
|
||||||
|
|
||||||
if (expectedChecksum !== checksumBits) {
|
|
||||||
// Explicitly zeroize on failure
|
|
||||||
entropy.fill(0);
|
|
||||||
throw new Error("Invalid mnemonic checksum");
|
|
||||||
}
|
|
||||||
|
|
||||||
return entropy;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -1,381 +0,0 @@
|
|||||||
/* @ts-self-types="./sss_recovery.d.ts" */
|
|
||||||
|
|
||||||
export class Share {
|
|
||||||
static __wrap(ptr) {
|
|
||||||
const obj = Object.create(Share.prototype);
|
|
||||||
obj.__wbg_ptr = ptr;
|
|
||||||
ShareFinalization.register(obj, obj.__wbg_ptr, obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
__destroy_into_raw() {
|
|
||||||
const ptr = this.__wbg_ptr;
|
|
||||||
this.__wbg_ptr = 0;
|
|
||||||
ShareFinalization.unregister(this);
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
free() {
|
|
||||||
const ptr = this.__destroy_into_raw();
|
|
||||||
wasm.__wbg_share_free(ptr, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* @returns {Uint8Array}
|
|
||||||
*/
|
|
||||||
get data() {
|
|
||||||
const ret = wasm.share_data(this.__wbg_ptr);
|
|
||||||
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
|
||||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
|
||||||
return v1;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* @param {number} x
|
|
||||||
* @param {Uint8Array} data
|
|
||||||
*/
|
|
||||||
constructor(x, data) {
|
|
||||||
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.share_new(x, ptr0, len0);
|
|
||||||
this.__wbg_ptr = ret;
|
|
||||||
ShareFinalization.register(this, this.__wbg_ptr, this);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
get x() {
|
|
||||||
const ret = wasm.share_x(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Symbol.dispose) Share.prototype[Symbol.dispose] = Share.prototype.free;
|
|
||||||
|
|
||||||
export function initialize() {
|
|
||||||
wasm.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Share} share1
|
|
||||||
* @param {Share} share2
|
|
||||||
* @returns {Uint8Array}
|
|
||||||
*/
|
|
||||||
export function reconstruct_secret(share1, share2) {
|
|
||||||
_assertClass(share1, Share);
|
|
||||||
_assertClass(share2, Share);
|
|
||||||
const ret = wasm.reconstruct_secret(share1.__wbg_ptr, share2.__wbg_ptr);
|
|
||||||
if (ret[3]) {
|
|
||||||
throw takeFromExternrefTable0(ret[2]);
|
|
||||||
}
|
|
||||||
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
|
||||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
|
||||||
return v1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Uint8Array} secret
|
|
||||||
* @returns {Array<any>}
|
|
||||||
*/
|
|
||||||
export function split_secret(secret) {
|
|
||||||
const ptr0 = passArray8ToWasm0(secret, wasm.__wbindgen_malloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.split_secret(ptr0, len0);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
function __wbg_get_imports() {
|
|
||||||
const import0 = {
|
|
||||||
__proto__: null,
|
|
||||||
__wbg___wbindgen_is_function_5e4570eb24ffa122: function(arg0) {
|
|
||||||
const ret = typeof(arg0) === 'function';
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg___wbindgen_is_object_a2790eb24c211ea0: function(arg0) {
|
|
||||||
const val = arg0;
|
|
||||||
const ret = typeof(val) === 'object' && val !== null;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg___wbindgen_is_string_e6f02f0ea5f20a32: function(arg0) {
|
|
||||||
const ret = typeof(arg0) === 'string';
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg___wbindgen_is_undefined_6cff064c44e0d823: function(arg0) {
|
|
||||||
const ret = arg0 === undefined;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg___wbindgen_throw_bb96b2010945f0bc: function(arg0, arg1) {
|
|
||||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
|
||||||
},
|
|
||||||
__wbg_call_35dba3c747ad7521: function() { return handleError(function (arg0, arg1, arg2) {
|
|
||||||
const ret = arg0.call(arg1, arg2);
|
|
||||||
return ret;
|
|
||||||
}, arguments); },
|
|
||||||
__wbg_crypto_38df2bab126b63dc: function(arg0) {
|
|
||||||
const ret = arg0.crypto;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
|
|
||||||
arg0.getRandomValues(arg1);
|
|
||||||
}, arguments); },
|
|
||||||
__wbg_length_36bd29c6848c2144: function(arg0) {
|
|
||||||
const ret = arg0.length;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
|
|
||||||
const ret = arg0.msCrypto;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_new_116be93542d39019: function() {
|
|
||||||
const ret = new Array();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_new_with_length_3ffc1c56427c525c: function(arg0) {
|
|
||||||
const ret = new Uint8Array(arg0 >>> 0);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_node_84ea875411254db1: function(arg0) {
|
|
||||||
const ret = arg0.node;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_process_44c7a14e11e9f69e: function(arg0) {
|
|
||||||
const ret = arg0.process;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_prototypesetcall_de8e0d9553586985: function(arg0, arg1, arg2) {
|
|
||||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
|
||||||
},
|
|
||||||
__wbg_push_adb0107829f02d75: function(arg0, arg1) {
|
|
||||||
const ret = arg0.push(arg1);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
|
|
||||||
arg0.randomFillSync(arg1);
|
|
||||||
}, arguments); },
|
|
||||||
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
|
|
||||||
const ret = module.require;
|
|
||||||
return ret;
|
|
||||||
}, arguments); },
|
|
||||||
__wbg_share_new: function(arg0) {
|
|
||||||
const ret = Share.__wrap(arg0);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_static_accessor_GLOBAL_THIS_466428f93b4eaa76: function() {
|
|
||||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
|
||||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
|
||||||
},
|
|
||||||
__wbg_static_accessor_GLOBAL_c7aea38d4de089bc: function() {
|
|
||||||
const ret = typeof global === 'undefined' ? null : global;
|
|
||||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
|
||||||
},
|
|
||||||
__wbg_static_accessor_SELF_42d4fae05e59267a: function() {
|
|
||||||
const ret = typeof self === 'undefined' ? null : self;
|
|
||||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
|
||||||
},
|
|
||||||
__wbg_static_accessor_WINDOW_e0db14a0eba6a812: function() {
|
|
||||||
const ret = typeof window === 'undefined' ? null : window;
|
|
||||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
|
||||||
},
|
|
||||||
__wbg_subarray_a4cc58201c7359fd: function(arg0, arg1, arg2) {
|
|
||||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_versions_276b2795b1c6a219: function(arg0) {
|
|
||||||
const ret = arg0.versions;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
|
||||||
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
|
||||||
const ret = getArrayU8FromWasm0(arg0, arg1);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
|
||||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
|
||||||
const ret = getStringFromWasm0(arg0, arg1);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_init_externref_table: function() {
|
|
||||||
const table = wasm.__wbindgen_externrefs;
|
|
||||||
const offset = table.grow(4);
|
|
||||||
table.set(0, undefined);
|
|
||||||
table.set(offset + 0, undefined);
|
|
||||||
table.set(offset + 1, null);
|
|
||||||
table.set(offset + 2, true);
|
|
||||||
table.set(offset + 3, false);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
__proto__: null,
|
|
||||||
"./sss_recovery_bg.js": import0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShareFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
||||||
? { register: () => {}, unregister: () => {} }
|
|
||||||
: new FinalizationRegistry(ptr => wasm.__wbg_share_free(ptr, 1));
|
|
||||||
|
|
||||||
function addToExternrefTable0(obj) {
|
|
||||||
const idx = wasm.__externref_table_alloc();
|
|
||||||
wasm.__wbindgen_externrefs.set(idx, obj);
|
|
||||||
return idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _assertClass(instance, klass) {
|
|
||||||
if (!(instance instanceof klass)) {
|
|
||||||
throw new Error(`expected instance of ${klass.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArrayU8FromWasm0(ptr, len) {
|
|
||||||
ptr = ptr >>> 0;
|
|
||||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStringFromWasm0(ptr, len) {
|
|
||||||
return decodeText(ptr >>> 0, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedUint8ArrayMemory0 = null;
|
|
||||||
function getUint8ArrayMemory0() {
|
|
||||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
|
||||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
|
||||||
}
|
|
||||||
return cachedUint8ArrayMemory0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleError(f, args) {
|
|
||||||
try {
|
|
||||||
return f.apply(this, args);
|
|
||||||
} catch (e) {
|
|
||||||
const idx = addToExternrefTable0(e);
|
|
||||||
wasm.__wbindgen_exn_store(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikeNone(x) {
|
|
||||||
return x === undefined || x === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function passArray8ToWasm0(arg, malloc) {
|
|
||||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
|
||||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
|
||||||
WASM_VECTOR_LEN = arg.length;
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeFromExternrefTable0(idx) {
|
|
||||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
|
||||||
wasm.__externref_table_dealloc(idx);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
||||||
cachedTextDecoder.decode();
|
|
||||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
|
||||||
let numBytesDecoded = 0;
|
|
||||||
function decodeText(ptr, len) {
|
|
||||||
numBytesDecoded += len;
|
|
||||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
|
||||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
||||||
cachedTextDecoder.decode();
|
|
||||||
numBytesDecoded = len;
|
|
||||||
}
|
|
||||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
|
||||||
}
|
|
||||||
|
|
||||||
let WASM_VECTOR_LEN = 0;
|
|
||||||
|
|
||||||
let wasmModule, wasmInstance, wasm;
|
|
||||||
function __wbg_finalize_init(instance, module) {
|
|
||||||
wasmInstance = instance;
|
|
||||||
wasm = instance.exports;
|
|
||||||
wasmModule = module;
|
|
||||||
cachedUint8ArrayMemory0 = null;
|
|
||||||
wasm.__wbindgen_start();
|
|
||||||
return wasm;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function __wbg_load(module, imports) {
|
|
||||||
if (typeof Response === 'function' && module instanceof Response) {
|
|
||||||
if (!module.ok) {
|
|
||||||
throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
|
||||||
try {
|
|
||||||
return await WebAssembly.instantiateStreaming(module, imports);
|
|
||||||
} catch (e) {
|
|
||||||
const validResponse = expectedResponseType(module.type);
|
|
||||||
|
|
||||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
|
||||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
|
||||||
|
|
||||||
} else { throw e; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = await module.arrayBuffer();
|
|
||||||
return await WebAssembly.instantiate(bytes, imports);
|
|
||||||
} else {
|
|
||||||
const instance = await WebAssembly.instantiate(module, imports);
|
|
||||||
|
|
||||||
if (instance instanceof WebAssembly.Instance) {
|
|
||||||
return { instance, module };
|
|
||||||
} else {
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectedResponseType(type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'basic': case 'cors': case 'default': return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSync(module) {
|
|
||||||
if (wasm !== undefined) return wasm;
|
|
||||||
|
|
||||||
|
|
||||||
if (module !== undefined) {
|
|
||||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
|
||||||
({module} = module)
|
|
||||||
} else {
|
|
||||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imports = __wbg_get_imports();
|
|
||||||
if (!(module instanceof WebAssembly.Module)) {
|
|
||||||
module = new WebAssembly.Module(module);
|
|
||||||
}
|
|
||||||
const instance = new WebAssembly.Instance(module, imports);
|
|
||||||
return __wbg_finalize_init(instance, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function __wbg_init(module_or_path) {
|
|
||||||
if (wasm !== undefined) return wasm;
|
|
||||||
|
|
||||||
|
|
||||||
if (module_or_path !== undefined) {
|
|
||||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
|
||||||
({module_or_path} = module_or_path)
|
|
||||||
} else {
|
|
||||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (module_or_path === undefined) {
|
|
||||||
module_or_path = new URL('sss_recovery_bg.wasm', import.meta.url);
|
|
||||||
}
|
|
||||||
const imports = __wbg_get_imports();
|
|
||||||
|
|
||||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
|
||||||
module_or_path = fetch(module_or_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
|
||||||
|
|
||||||
return __wbg_finalize_init(instance, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { initSync, __wbg_init as default };
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { WORDLIST } from "./bip39_wordlist.ts";
|
|
||||||
|
|
||||||
export async function entropyToMnemonic(entropy: Uint8Array): Promise<string> {
|
|
||||||
if (entropy.length < 16 || entropy.length > 32 || entropy.length % 4 !== 0) {
|
|
||||||
throw new Error("Invalid entropy length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const entropyBits = Array.from(entropy)
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const entropyBuffer = new Uint8Array(entropy.length);
|
|
||||||
entropyBuffer.set(entropy);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropyBuffer);
|
|
||||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const checksumLength = entropy.length / 4;
|
|
||||||
const checksum = hashBits.slice(0, checksumLength);
|
|
||||||
|
|
||||||
const bits = entropyBits + checksum;
|
|
||||||
const chunks = bits.match(/(.{1,11})/g) || [];
|
|
||||||
|
|
||||||
const mnemonic = chunks.map((binaryStr) => {
|
|
||||||
const index = parseInt(binaryStr, 2);
|
|
||||||
return WORDLIST[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
return mnemonic.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mnemonicToEntropy(mnemonic: string): Promise<Uint8Array> {
|
|
||||||
const words = mnemonic.normalize("NFKD").trim().split(/\s+/);
|
|
||||||
if (words.length % 3 !== 0) {
|
|
||||||
throw new Error("Invalid mnemonic length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bits = words
|
|
||||||
.map((word) => {
|
|
||||||
const index = WORDLIST.indexOf(word);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Invalid word in mnemonic: ${word}`);
|
|
||||||
}
|
|
||||||
return index.toString(2).padStart(11, "0");
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const dividerIndex = Math.floor(bits.length / 33) * 32;
|
|
||||||
const entropyBits = bits.slice(0, dividerIndex);
|
|
||||||
const checksumBits = bits.slice(dividerIndex);
|
|
||||||
|
|
||||||
const entropy = new Uint8Array(entropyBits.length / 8);
|
|
||||||
for (let i = 0; i < entropy.length; i++) {
|
|
||||||
entropy[i] = parseInt(entropyBits.slice(i * 8, (i + 1) * 8), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropy);
|
|
||||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
const expectedChecksum = hashBits.slice(0, checksumBits.length);
|
|
||||||
|
|
||||||
if (expectedChecksum !== checksumBits) {
|
|
||||||
// Explicitly zeroize on failure
|
|
||||||
entropy.fill(0);
|
|
||||||
throw new Error("Invalid mnemonic checksum");
|
|
||||||
}
|
|
||||||
|
|
||||||
return entropy;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
209
wasm/sss_recovery/Cargo.lock
generated
209
wasm/sss_recovery/Cargo.lock
generated
@ -1,209 +0,0 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
|
||||||
# It is not intended for manual editing.
|
|
||||||
version = 4
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bumpalo"
|
|
||||||
version = "3.20.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg-if"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-core"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-task"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-util"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"futures-task",
|
|
||||||
"pin-project-lite",
|
|
||||||
"slab",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"js-sys",
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "js-sys"
|
|
||||||
version = "0.3.104"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"futures-util",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libc"
|
|
||||||
version = "0.2.189"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell"
|
|
||||||
version = "1.21.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project-lite"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro2"
|
|
||||||
version = "1.0.107"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quote"
|
|
||||||
version = "1.0.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustversion"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "slab"
|
|
||||||
version = "0.4.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sss_recovery"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "2.0.119"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-ident"
|
|
||||||
version = "1.0.24"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasi"
|
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen"
|
|
||||||
version = "0.2.127"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"once_cell",
|
|
||||||
"rustversion",
|
|
||||||
"wasm-bindgen-macro",
|
|
||||||
"wasm-bindgen-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-macro"
|
|
||||||
version = "0.2.127"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"wasm-bindgen-macro-support",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-macro-support"
|
|
||||||
version = "0.2.127"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"wasm-bindgen-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-shared"
|
|
||||||
version = "0.2.127"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize"
|
|
||||||
version = "1.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
|
||||||
dependencies = [
|
|
||||||
"zeroize_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize_derive"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "sss_recovery"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
getrandom = { version = "0.2", features = ["js"] }
|
|
||||||
js-sys = "0.3.104"
|
|
||||||
wasm-bindgen = "0.2.127"
|
|
||||||
zeroize = { version = "1.9.0", features = ["zeroize_derive"] }
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
use getrandom::getrandom;
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn initialize() {
|
|
||||||
// Optional init
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(always)]
|
|
||||||
fn gf_mul(mut a: u8, mut b: u8) -> u8 {
|
|
||||||
let mut p = 0;
|
|
||||||
for _ in 0..8 {
|
|
||||||
let mask = 0u8.wrapping_sub(b & 1);
|
|
||||||
p ^= a & mask;
|
|
||||||
let hi_bit_set = 0u8.wrapping_sub(a >> 7);
|
|
||||||
a = (a << 1) ^ (0x1B & hi_bit_set);
|
|
||||||
b >>= 1;
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
fn gf_inv(a: u8) -> u8 {
|
|
||||||
if a == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut res = a;
|
|
||||||
for _ in 0..253 {
|
|
||||||
res = gf_mul(res, a);
|
|
||||||
}
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
|
||||||
pub struct SecretBuffer(pub Vec<u8>);
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub struct Share {
|
|
||||||
x: u8,
|
|
||||||
data: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
impl Share {
|
|
||||||
#[wasm_bindgen(constructor)]
|
|
||||||
pub fn new(x: u8, data: &[u8]) -> Share {
|
|
||||||
Share {
|
|
||||||
x,
|
|
||||||
data: data.to_vec(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(getter)]
|
|
||||||
pub fn x(&self) -> u8 {
|
|
||||||
self.x
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(getter)]
|
|
||||||
pub fn data(&self) -> Vec<u8> {
|
|
||||||
self.data.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split into 3 shares (2-of-3)
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn split_secret(secret: &[u8]) -> Result<js_sys::Array, JsValue> {
|
|
||||||
if secret.is_empty() {
|
|
||||||
return Err(JsValue::from_str("Secret cannot be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut secret_buf = SecretBuffer(secret.to_vec());
|
|
||||||
let mut a1_buf = SecretBuffer(vec![0u8; secret.len()]);
|
|
||||||
|
|
||||||
// Generate random coefficients
|
|
||||||
getrandom(&mut a1_buf.0).map_err(|_| JsValue::from_str("Failed to generate random bytes"))?;
|
|
||||||
|
|
||||||
let arr = js_sys::Array::new();
|
|
||||||
|
|
||||||
for x in 1..=3u8 {
|
|
||||||
let mut share_data = vec![0u8; secret.len()];
|
|
||||||
for i in 0..secret.len() {
|
|
||||||
let s = secret_buf.0[i];
|
|
||||||
let a1 = a1_buf.0[i];
|
|
||||||
share_data[i] = s ^ gf_mul(a1, x);
|
|
||||||
}
|
|
||||||
let share = Share::new(x, &share_data);
|
|
||||||
arr.push(&JsValue::from(share));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn reconstruct_secret(share1: &Share, share2: &Share) -> Result<Vec<u8>, JsValue> {
|
|
||||||
if share1.x == share2.x {
|
|
||||||
return Err(JsValue::from_str("Shares must have different X coordinates"));
|
|
||||||
}
|
|
||||||
if share1.data.len() != share2.data.len() {
|
|
||||||
return Err(JsValue::from_str("Shares must have the same length"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut secret = SecretBuffer(vec![0u8; share1.data.len()]);
|
|
||||||
|
|
||||||
let xa = share1.x;
|
|
||||||
let xb = share2.x;
|
|
||||||
|
|
||||||
let delta = xa ^ xb;
|
|
||||||
let inv_delta = gf_inv(delta);
|
|
||||||
|
|
||||||
let l0 = gf_mul(xb, inv_delta);
|
|
||||||
let l1 = gf_mul(xa, inv_delta);
|
|
||||||
|
|
||||||
for i in 0..secret.0.len() {
|
|
||||||
let ya = share1.data[i];
|
|
||||||
let yb = share2.data[i];
|
|
||||||
|
|
||||||
let s0 = gf_mul(ya, l0);
|
|
||||||
let s1 = gf_mul(yb, l1);
|
|
||||||
|
|
||||||
secret.0[i] = s0 ^ s1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return cloned data; caller in JS MUST fill(0) on returned array
|
|
||||||
let result = secret.0.clone();
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user