feat(hololake): add mobile server account and capability proxy

This commit is contained in:
冰朔 2026-08-02 21:49:53 +08:00
commit 19bd38c6e3
11 changed files with 1436 additions and 1 deletions

View file

@ -27,6 +27,22 @@
- 所有改变服务器状态的登记动作必须先生成备份引用和回滚方案;失败自动回滚,成功写验收回执;
- 广州公开代理只应暴露 `/approve/``/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。
## HoloLake 手机身份、知识湖与模型代理
HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准链接:
- `/api/hololake/session/email/request` 对已登记和未登记邮箱返回相同形态,避免枚举账号;
- 六位验证码只在邮件正文出现,服务器状态仅保存带私密 pepper 的摘要,十分钟失效且最多尝试五次;
- 会话绑定 HoloLake 设备编号,手机仅保存短期会话令牌;服务器磁盘仍只保存令牌摘要;
- `/api/hololake/knowledge/manifest``/archive` 只暴露固定登记仓库
`bingshuo/hololake-knowledge-base` 的当前 `refs/heads/main` 快照,不向手机下发 Forgejo 凭据;
- `/api/hololake/ai/catalog` 只返回可用模型编号;`/execute` 只调用服务器登记的提供商与模型,
不接受任意 URL、请求头或密钥响应和回执均不包含服务器密钥。
私密模型登记文件使用 `hololake-ai-providers.example.json` 的结构,真实文件只放在
`/etc/guanghu/secrets/hololake-ai-providers.json`,不得提交到仓库。知识仓库路径、模型
登记文件、session pepper 和会话状态路径由 `authorization.env` 固定;手机不能切换这些路径。
`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密
request credential 时,脚本自动切换到跨设备公开建单模式。

View file

@ -20,6 +20,14 @@ SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USER=SET_IN_PRIVATE_SERVER_FILE
QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE
HOLOLAKE_SESSION_PEPPER=SET_RANDOM_32_BYTE_VALUE_IN_PRIVATE_SERVER_FILE
HOLOLAKE_SESSION_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/hololake-sessions.json
HOLOLAKE_OTP_TTL=600
HOLOLAKE_ACCOUNT_SESSION_TTL=86400
HOLOLAKE_OTP_REQUEST_LIMIT=6
HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH=/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git
HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES=134217728
HOLOLAKE_AI_PROVIDERS_FILE=/etc/guanghu/secrets/hololake-ai-providers.json
LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET=/run/guanghu-architecture-provision/provision.sock
ARCHITECTURE_PROVISION_REPO_URL=https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git
ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo

View file

@ -0,0 +1,14 @@
{
"schema": "guanghu.hololake-ai-providers/v1",
"providers": {
"default": {
"name": "HoloLake Server Model",
"base_url": "https://api.example.invalid/v1",
"api_key": "SET_IN_PRIVATE_SERVER_FILE",
"models": [
"SET_REGISTERED_MODEL_ID"
],
"timeout_ms": 120000
}
}
}

View file

@ -0,0 +1,236 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const {
HoloLakeSessionManager,
} = require("./hololake-session");
const { createApp } = require("./server");
async function withServer(run) {
const mail = [];
const sessionManager = new HoloLakeSessionManager({
registeredEmails: ["owner@example.invalid"],
pepper: "test-only-pepper-with-enough-entropy",
stateFile: "",
sendEmail: async message => {
mail.push(message);
return true;
},
});
const knowledgeProvider = {
manifest: () => ({
schema: "guanghu.hololake-knowledge-manifest/v1",
repository: "bingshuo/hololake-knowledge-base",
ref: "refs/heads/main",
commit: "a".repeat(40),
committed_at: 1_800_000_000,
archive_url: `/api/hololake/knowledge/archive?commit=${"a".repeat(40)}`,
}),
archive: commit => ({
schema: "guanghu.hololake-knowledge-archive/v1",
repository: "bingshuo/hololake-knowledge-base",
commit,
sha256: "b".repeat(64),
content_type: "application/zip",
body: Buffer.from("PK-test-archive"),
}),
};
const aiGateway = {
catalog: () => ({
schema: "guanghu.hololake-ai-catalog/v1",
providers: [{ id: "default", name: "HoloLake", models: ["gpt-test"] }],
}),
execute: async body => ({
ok: true,
response: {
choices: [{
message: {
role: "assistant",
content: body.messages[0].content,
},
}],
},
receipt: {
schema: "guanghu.hololake-ai-receipt/v1",
state: "executed",
provider: body.provider,
model: body.model,
},
}),
};
const app = createApp({
requestToken: "request-only-secret",
ownerEmail: "owner@example.invalid",
publicBaseUrl: "https://example.invalid/authz",
stateFile: "",
sendEmail: async () => true,
hololakeSessionManager: sessionManager,
hololakeKnowledgeProvider: knowledgeProvider,
hololakeAiGateway: aiGateway,
});
await new Promise(resolve => app.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${app.address().port}`;
try {
await run({ base, mail });
} finally {
app.closeAllConnections?.();
await new Promise(resolve => app.close(resolve));
}
}
async function login(base, mail, deviceId = "ios-device-001") {
const request = await fetch(`${base}/api/hololake/session/email/request`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-hololake-device-id": deviceId,
},
body: JSON.stringify({
email: "owner@example.invalid",
device_id: deviceId,
}),
});
assert.equal(request.status, 202);
const requested = await request.json();
const code = mail[0].text.match(/\b\d{6}\b/)[0];
const verifiedResponse = await fetch(
`${base}/api/hololake/session/email/verify`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-hololake-device-id": deviceId,
},
body: JSON.stringify({
request_id: requested.request_id,
code,
device_id: deviceId,
}),
},
);
assert.equal(verifiedResponse.status, 200);
return (await verifiedResponse.json()).session_token;
}
test("HoloLake session API is non-enumerating and rejects invalid verification", async () => {
await withServer(async ({ base, mail }) => {
const unknown = await fetch(
`${base}/api/hololake/session/email/request`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "nobody@example.invalid",
device_id: "ios-device-001",
}),
},
);
assert.equal(unknown.status, 202);
assert.equal(mail.length, 0);
assert.equal((await unknown.json()).accepted, true);
const invalid = await fetch(
`${base}/api/hololake/session/email/verify`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
request_id: crypto.randomUUID(),
code: "000000",
device_id: "ios-device-001",
}),
},
);
assert.equal(invalid.status, 403);
assert.equal((await invalid.json()).error, "invalid_or_expired_code");
});
});
test("authenticated device can read fixed knowledge, use AI proxy, inspect and revoke session", async () => {
await withServer(async ({ base, mail }) => {
const deviceId = "ios-device-001";
const token = await login(base, mail, deviceId);
const headers = {
authorization: `Bearer ${token}`,
"x-hololake-device-id": deviceId,
};
const session = await fetch(`${base}/api/hololake/session`, { headers });
assert.equal(session.status, 200);
assert.equal((await session.json()).session.device_id, deviceId);
const manifest = await fetch(
`${base}/api/hololake/knowledge/manifest`,
{ headers },
);
assert.equal(manifest.status, 200);
assert.equal((await manifest.json()).commit, "a".repeat(40));
const archive = await fetch(
`${base}/api/hololake/knowledge/archive?commit=${"a".repeat(40)}`,
{ headers },
);
assert.equal(archive.status, 200);
assert.equal(archive.headers.get("x-hololake-commit"), "a".repeat(40));
assert.equal(archive.headers.get("x-content-sha256"), "b".repeat(64));
assert.equal(Buffer.from(await archive.arrayBuffer()).toString(), "PK-test-archive");
const ai = await fetch(`${base}/api/hololake/ai/execute`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({
provider: "default",
model: "gpt-test",
messages: [{ role: "user", content: "hello" }],
}),
});
assert.equal(ai.status, 200);
assert.equal(
(await ai.json()).response.choices[0].message.content,
"hello",
);
const catalog = await fetch(`${base}/api/hololake/ai/catalog`, { headers });
assert.equal(catalog.status, 200);
assert.deepEqual((await catalog.json()).providers[0].models, ["gpt-test"]);
const logout = await fetch(`${base}/api/hololake/session`, {
method: "DELETE",
headers,
});
assert.equal(logout.status, 200);
assert.equal(
(await fetch(`${base}/api/hololake/session`, { headers })).status,
401,
);
});
});
test("knowledge and AI endpoints require the session and matching device", async () => {
await withServer(async ({ base, mail }) => {
assert.equal(
(await fetch(`${base}/api/hololake/knowledge/manifest`)).status,
401,
);
assert.equal(
(await fetch(`${base}/api/hololake/ai/execute`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
})).status,
401,
);
const token = await login(base, mail);
assert.equal(
(await fetch(`${base}/api/hololake/knowledge/manifest`, {
headers: {
authorization: `Bearer ${token}`,
"x-hololake-device-id": "ios-device-002",
},
})).status,
403,
);
});
});

View file

@ -0,0 +1,242 @@
"use strict";
const childProcess = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs");
class HoloLakeKnowledgeProvider {
constructor(options = {}) {
this.repositoryId = String(options.repositoryId || "");
this.repositoryPath = String(options.repositoryPath || "");
this.ref = "refs/heads/main";
this.maxArchiveBytes = Math.max(
1024 * 1024,
Number(options.maxArchiveBytes || 128 * 1024 * 1024),
);
if (!/^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(this.repositoryId)) {
throw new Error("knowledge_repository_id_invalid");
}
if (!this.repositoryPath || !fs.existsSync(this.repositoryPath)) {
throw new Error("knowledge_repository_unavailable");
}
}
manifest() {
const commit = this.git(["rev-parse", "--verify", `${this.ref}^{commit}`])
.toString("utf8")
.trim();
if (!/^[0-9a-f]{40}$/.test(commit)) {
throw new Error("knowledge_commit_invalid");
}
const committedAt = Number(
this.git(["show", "-s", "--format=%ct", commit]).toString("utf8").trim(),
);
return {
schema: "guanghu.hololake-knowledge-manifest/v1",
repository: this.repositoryId,
ref: this.ref,
commit,
committed_at: committedAt,
archive_url: `/api/hololake/knowledge/archive?commit=${commit}`,
};
}
archive(commit) {
const manifest = this.manifest();
if (!safeEqual(String(commit || ""), manifest.commit)) {
throw new Error("knowledge_commit_not_current");
}
const body = this.git(
["archive", "--format=zip", manifest.commit],
this.maxArchiveBytes + 1,
);
if (body.length > this.maxArchiveBytes) {
throw new Error("knowledge_archive_too_large");
}
return {
schema: "guanghu.hololake-knowledge-archive/v1",
repository: this.repositoryId,
commit: manifest.commit,
sha256: crypto.createHash("sha256").update(body).digest("hex"),
content_type: "application/zip",
body,
};
}
git(args, maxBuffer = 1024 * 1024) {
return childProcess.execFileSync(
"git",
[`--git-dir=${this.repositoryPath}`, ...args],
{
encoding: "buffer",
maxBuffer,
stdio: ["ignore", "pipe", "pipe"],
},
);
}
}
class HoloLakeAiGateway {
constructor(options = {}) {
this.fetchImpl = options.fetchImpl || fetch;
this.maxMessages = Math.max(1, Number(options.maxMessages || 64));
this.maxInputCharacters = Math.max(
1,
Number(options.maxInputCharacters || 120_000),
);
this.maxOutputTokens = Math.max(
16,
Number(options.maxOutputTokens || 16_384),
);
this.maxResponseBytes = Math.max(
64 * 1024,
Number(options.maxResponseBytes || 2 * 1024 * 1024),
);
this.providers = normalizeProviders(options.providers || {});
}
catalog() {
return {
schema: "guanghu.hololake-ai-catalog/v1",
providers: Object.entries(this.providers).map(([id, provider]) => ({
id,
name: provider.name || id,
models: provider.models,
})),
};
}
async execute(request = {}) {
const providerId = String(request.provider || "");
const provider = this.providers[providerId];
if (!provider) throw new Error("ai_provider_not_registered");
const model = String(request.model || "");
if (!provider.models.includes(model)) {
throw new Error("ai_model_not_registered");
}
const messages = validateMessages(
request.messages,
this.maxMessages,
this.maxInputCharacters,
);
const maxTokens = Number(request.max_tokens || 1024);
if (
!Number.isInteger(maxTokens)
|| maxTokens < 1
|| maxTokens > this.maxOutputTokens
) {
throw new Error("ai_output_limit_exceeded");
}
const response = await this.fetchImpl(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
authorization: `Bearer ${provider.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model,
messages,
max_tokens: maxTokens,
stream: false,
}),
signal: AbortSignal.timeout(provider.timeoutMs),
});
const text = await response.text();
if (Buffer.byteLength(text) > this.maxResponseBytes) {
throw new Error("ai_response_too_large");
}
if (!response.ok) {
const error = new Error("ai_upstream_failed");
error.status = response.status;
throw error;
}
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("ai_upstream_invalid_json");
}
return {
ok: true,
response: parsed,
receipt: {
schema: "guanghu.hololake-ai-receipt/v1",
state: "executed",
provider: providerId,
model,
upstream_status: response.status,
occurred_at: Date.now() / 1000,
},
};
}
}
function loadAiProviders(file) {
if (!file || !fs.existsSync(file)) return {};
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!parsed || parsed.schema !== "guanghu.hololake-ai-providers/v1") {
throw new Error("ai_provider_registry_invalid");
}
return parsed.providers || {};
}
function normalizeProviders(providers) {
const normalized = {};
for (const [id, value] of Object.entries(providers)) {
if (!/^[a-z0-9._-]{1,64}$/.test(id) || !value) continue;
const baseUrl = String(value.baseUrl || value.base_url || "").replace(/\/$/, "");
const apiKey = String(value.apiKey || value.api_key || "");
const models = Array.isArray(value.models)
? value.models.map(String).filter(model => /^[A-Za-z0-9._:/-]{1,160}$/.test(model))
: [];
let parsed;
try {
parsed = new URL(baseUrl);
} catch {
continue;
}
if (parsed.protocol !== "https:" || !apiKey || models.length === 0) continue;
normalized[id] = {
baseUrl,
apiKey,
name: String(value.name || id).slice(0, 120),
models,
timeoutMs: Math.max(1_000, Number(value.timeoutMs || value.timeout_ms || 120_000)),
};
}
return normalized;
}
function validateMessages(messages, maxMessages, maxInputCharacters) {
if (!Array.isArray(messages) || messages.length < 1) {
throw new Error("ai_messages_required");
}
if (messages.length > maxMessages) throw new Error("ai_input_too_large");
let characters = 0;
const normalized = messages.map(message => {
if (
!message
|| !["system", "user", "assistant"].includes(message.role)
|| typeof message.content !== "string"
) {
throw new Error("ai_message_invalid");
}
characters += message.content.length;
return { role: message.role, content: message.content };
});
if (characters > maxInputCharacters) throw new Error("ai_input_too_large");
return normalized;
}
function safeEqual(left, right) {
const a = Buffer.from(String(left));
const b = Buffer.from(String(right));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = {
HoloLakeAiGateway,
HoloLakeKnowledgeProvider,
loadAiProviders,
};

View file

@ -0,0 +1,158 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
HoloLakeAiGateway,
HoloLakeKnowledgeProvider,
} = require("./hololake-capabilities");
function createBareKnowledgeRepository() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hololake-knowledge-"));
const work = path.join(root, "work");
const bare = path.join(root, "knowledge.git");
fs.mkdirSync(work);
childProcess.execFileSync("git", ["init", "-b", "main"], { cwd: work });
childProcess.execFileSync("git", ["config", "user.email", "test@example.invalid"], { cwd: work });
childProcess.execFileSync("git", ["config", "user.name", "HoloLake Test"], { cwd: work });
fs.writeFileSync(path.join(work, "INDEX.md"), "# HoloLake\n");
childProcess.execFileSync("git", ["add", "INDEX.md"], { cwd: work });
childProcess.execFileSync("git", ["commit", "-m", "knowledge baseline"], { cwd: work });
childProcess.execFileSync("git", ["clone", "--bare", work, bare]);
return { root, bare };
}
test("knowledge provider exposes only the fixed main commit and archive", () => {
const repository = createBareKnowledgeRepository();
const provider = new HoloLakeKnowledgeProvider({
repositoryId: "bingshuo/hololake-knowledge-base",
repositoryPath: repository.bare,
maxArchiveBytes: 2 * 1024 * 1024,
});
const manifest = provider.manifest();
assert.equal(
manifest.repository,
"bingshuo/hololake-knowledge-base",
);
assert.match(manifest.commit, /^[0-9a-f]{40}$/);
assert.equal(manifest.ref, "refs/heads/main");
const archive = provider.archive(manifest.commit);
assert.equal(archive.commit, manifest.commit);
assert.equal(archive.content_type, "application/zip");
assert.equal(archive.body.subarray(0, 2).toString("ascii"), "PK");
assert.equal(archive.sha256.length, 64);
assert.throws(
() => provider.archive("0000000000000000000000000000000000000000"),
/knowledge_commit_not_current/,
);
});
test("AI gateway accepts only registered provider models and never leaks the key", async () => {
const requests = [];
const gateway = new HoloLakeAiGateway({
providers: {
default: {
baseUrl: "https://models.example.invalid/v1",
apiKey: "server-secret-key",
models: ["gpt-test"],
},
},
fetchImpl: async (url, options) => {
requests.push({ url, options });
return new Response(JSON.stringify({
id: "response-1",
choices: [{ message: { role: "assistant", content: "ok" } }],
usage: { total_tokens: 3 },
}), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
const result = await gateway.execute({
provider: "default",
model: "gpt-test",
messages: [{ role: "user", content: "hello" }],
max_tokens: 32,
});
assert.equal(result.ok, true);
assert.equal(result.response.choices[0].message.content, "ok");
assert.equal(result.receipt.provider, "default");
assert.equal(requests.length, 1);
assert.equal(
requests[0].options.headers.authorization,
"Bearer server-secret-key",
);
assert.doesNotMatch(JSON.stringify(result), /server-secret-key/);
const catalog = gateway.catalog();
assert.deepEqual(catalog.providers[0].models, ["gpt-test"]);
assert.doesNotMatch(JSON.stringify(catalog), /server-secret-key|models\\.example/);
await assert.rejects(
() => gateway.execute({
provider: "default",
model: "not-registered",
messages: [{ role: "user", content: "hello" }],
}),
/ai_model_not_registered/,
);
await assert.rejects(
() => gateway.execute({
provider: "https://attacker.invalid/v1",
model: "gpt-test",
messages: [{ role: "user", content: "hello" }],
}),
/ai_provider_not_registered/,
);
assert.equal(requests.length, 1);
});
test("AI gateway bounds message shape, count, text, and requested output", async () => {
const gateway = new HoloLakeAiGateway({
providers: {
default: {
baseUrl: "https://models.example.invalid/v1",
apiKey: "server-secret-key",
models: ["gpt-test"],
},
},
fetchImpl: async () => new Response("{}", { status: 200 }),
maxMessages: 2,
maxInputCharacters: 10,
maxOutputTokens: 64,
});
await assert.rejects(
() => gateway.execute({
provider: "default",
model: "gpt-test",
messages: [{ role: "user", content: "12345678901" }],
}),
/ai_input_too_large/,
);
await assert.rejects(
() => gateway.execute({
provider: "default",
model: "gpt-test",
messages: [{ role: "tool", content: "hello" }],
}),
/ai_message_invalid/,
);
await assert.rejects(
() => gateway.execute({
provider: "default",
model: "gpt-test",
messages: [{ role: "user", content: "hello" }],
max_tokens: 65,
}),
/ai_output_limit_exceeded/,
);
});

View file

@ -0,0 +1,260 @@
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
class HoloLakeSessionManager {
constructor(options = {}) {
this.now = options.now || Date.now;
this.sendEmail = options.sendEmail || (async () => false);
this.pepper = String(options.pepper || "");
if (this.pepper.length < 24) {
throw new Error("HOLOLAKE_SESSION_PEPPER must contain at least 24 characters");
}
this.stateFile = String(options.stateFile || "");
this.otpTtlMs = Number(options.otpTtlSeconds || 10 * 60) * 1000;
this.sessionTtlMs = Number(options.sessionTtlSeconds || 24 * 60 * 60) * 1000;
this.maxOtpAttempts = Math.max(1, Number(options.maxOtpAttempts || 5));
this.requestLimit = Math.max(1, Number(options.requestLimit || 6));
this.requestWindowMs = Number(options.requestWindowSeconds || 60 * 60) * 1000;
this.registeredEmails = new Map(
(options.registeredEmails || [])
.map(normalizeEmail)
.filter(validEmail)
.map(email => [email, this.digest(`account:${email}`)]),
);
this.requestEvents = new Map();
this.state = this.loadState();
}
async requestOtp({ email, deviceId, networkKey = "unknown" }) {
const now = this.now();
this.prune(now);
const normalizedDevice = normalizeDeviceId(deviceId);
const requestId = crypto.randomUUID();
if (!normalizedDevice) return { accepted: false, error: "invalid_device" };
if (!this.takeRequest(String(networkKey || "unknown"), now)) {
return { accepted: false, error: "rate_limited" };
}
const normalizedEmail = normalizeEmail(email);
const accountId = this.registeredEmails.get(normalizedEmail);
if (!accountId) return { accepted: true, request_id: requestId };
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
this.state.challenges[requestId] = {
account_id: accountId,
device_id: normalizedDevice,
code_digest: this.digest(
`otp:${requestId}:${normalizedDevice}:${code}`,
),
attempts: 0,
expires_at: now + this.otpTtlMs,
};
this.persist();
const sent = await this.sendEmail({
to: normalizedEmail,
subject: "HoloLake 登录验证码",
text: [
`你的 HoloLake 登录验证码是:${code}`,
`验证码将在 ${Math.ceil(this.otpTtlMs / 60_000)} 分钟后失效。`,
"如果不是你本人操作,请忽略这封邮件。",
].join("\n"),
});
if (!sent) {
delete this.state.challenges[requestId];
this.persist();
}
return { accepted: true, request_id: requestId };
}
verifyOtp({ requestId, code, deviceId }) {
const now = this.now();
this.prune(now);
const id = String(requestId || "");
const challenge = this.state.challenges[id];
const normalizedDevice = normalizeDeviceId(deviceId);
if (
!challenge
|| !normalizedDevice
|| challenge.expires_at <= now
|| challenge.attempts >= this.maxOtpAttempts
) {
if (challenge) {
delete this.state.challenges[id];
this.persist();
}
return invalidCode();
}
challenge.attempts += 1;
const candidate = this.digest(
`otp:${id}:${normalizedDevice}:${String(code || "")}`,
);
const matches = safeEqual(candidate, challenge.code_digest)
&& safeEqual(normalizedDevice, challenge.device_id);
if (!matches) {
if (challenge.attempts >= this.maxOtpAttempts) {
delete this.state.challenges[id];
}
this.persist();
return invalidCode();
}
delete this.state.challenges[id];
const token = crypto.randomBytes(48).toString("base64url");
const tokenDigest = this.digest(`session:${token}`);
this.state.sessions[tokenDigest] = {
account_id: challenge.account_id,
device_id: normalizedDevice,
created_at: now,
expires_at: now + this.sessionTtlMs,
};
this.persist();
return {
ok: true,
session_token: token,
expires_at: (now + this.sessionTtlMs) / 1000,
expires_in: this.sessionTtlMs / 1000,
};
}
authenticate(token, deviceId) {
const now = this.now();
const normalizedDevice = normalizeDeviceId(deviceId);
const tokenDigest = this.digest(`session:${String(token || "")}`);
const session = this.state.sessions[tokenDigest];
if (!session) return { ok: false, error: "session_invalid" };
if (session.expires_at <= now) {
delete this.state.sessions[tokenDigest];
this.persist();
return { ok: false, error: "session_expired" };
}
if (!normalizedDevice || !safeEqual(normalizedDevice, session.device_id)) {
return { ok: false, error: "session_device_mismatch" };
}
return {
ok: true,
session: {
account_id: session.account_id,
device_id: session.device_id,
created_at: session.created_at / 1000,
expires_at: session.expires_at / 1000,
},
};
}
revoke(token, deviceId) {
const authenticated = this.authenticate(token, deviceId);
if (!authenticated.ok) return authenticated;
const tokenDigest = this.digest(`session:${String(token || "")}`);
delete this.state.sessions[tokenDigest];
this.persist();
return { ok: true };
}
inspectState() {
return JSON.parse(JSON.stringify(this.state));
}
digest(value) {
return crypto.createHmac("sha256", this.pepper).update(value).digest("hex");
}
takeRequest(key, now) {
const previous = (this.requestEvents.get(key) || [])
.filter(timestamp => now - timestamp < this.requestWindowMs);
if (previous.length >= this.requestLimit) {
this.requestEvents.set(key, previous);
return false;
}
previous.push(now);
this.requestEvents.set(key, previous);
return true;
}
prune(now) {
let changed = false;
for (const [id, challenge] of Object.entries(this.state.challenges)) {
if (challenge.expires_at <= now) {
delete this.state.challenges[id];
changed = true;
}
}
for (const [digest, session] of Object.entries(this.state.sessions)) {
if (session.expires_at <= now) {
delete this.state.sessions[digest];
changed = true;
}
}
if (changed) this.persist();
}
loadState() {
if (!this.stateFile || !fs.existsSync(this.stateFile)) {
return emptyState();
}
const parsed = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
if (
!parsed
|| parsed.schema !== "guanghu.hololake-session-state/v1"
|| !isRecord(parsed.challenges)
|| !isRecord(parsed.sessions)
) {
throw new Error("invalid HoloLake session state");
}
return parsed;
}
persist() {
if (!this.stateFile) return;
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 });
const temporary = `${this.stateFile}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(this.state, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
fs.renameSync(temporary, this.stateFile);
fs.chmodSync(this.stateFile, 0o600);
}
}
function emptyState() {
return {
schema: "guanghu.hololake-session-state/v1",
challenges: {},
sessions: {},
};
}
function invalidCode() {
return { ok: false, error: "invalid_or_expired_code" };
}
function normalizeEmail(value) {
return String(value || "").trim().normalize("NFKC").toLowerCase();
}
function normalizeDeviceId(value) {
const normalized = String(value || "").trim();
return /^[A-Za-z0-9._:-]{8,120}$/.test(normalized) ? normalized : "";
}
function validEmail(value) {
return value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value);
}
function safeEqual(left, right) {
const a = Buffer.from(String(left));
const b = Buffer.from(String(right));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function isRecord(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
module.exports = {
HoloLakeSessionManager,
};

View file

@ -0,0 +1,211 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
HoloLakeSessionManager,
} = require("./hololake-session");
function fixture(overrides = {}) {
let now = 1_800_000_000_000;
const mail = [];
const manager = new HoloLakeSessionManager({
registeredEmails: ["owner@example.invalid"],
pepper: "test-only-pepper-with-enough-entropy",
stateFile: "",
now: () => now,
sendEmail: async message => {
mail.push(message);
return true;
},
...overrides,
});
return {
manager,
mail,
advance(milliseconds) {
now += milliseconds;
},
};
}
test("OTP request is non-enumerating and stores no plaintext code", async () => {
const known = fixture();
const requested = await known.manager.requestOtp({
email: "Owner@Example.Invalid",
deviceId: "ios-device-001",
});
assert.equal(requested.accepted, true);
assert.match(requested.request_id, /^[0-9a-f-]{36}$/);
assert.equal(known.mail.length, 1);
assert.match(known.mail[0].text, /\b\d{6}\b/);
const otp = known.mail[0].text.match(/\b\d{6}\b/)[0];
assert.doesNotMatch(JSON.stringify(known.manager.inspectState()), new RegExp(otp));
const unknown = fixture();
const decoy = await unknown.manager.requestOtp({
email: "nobody@example.invalid",
deviceId: "ios-device-001",
});
assert.equal(decoy.accepted, requested.accepted);
assert.match(decoy.request_id, /^[0-9a-f-]{36}$/);
assert.equal(unknown.mail.length, 0);
});
test("OTP verification is device-bound, attempt-limited, and returns a one-time session token", async () => {
const state = fixture();
const requested = await state.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
});
const otp = state.mail[0].text.match(/\b\d{6}\b/)[0];
const wrongDevice = state.manager.verifyOtp({
requestId: requested.request_id,
code: otp,
deviceId: "ios-device-002",
});
assert.equal(wrongDevice.ok, false);
assert.equal(wrongDevice.error, "invalid_or_expired_code");
const verified = state.manager.verifyOtp({
requestId: requested.request_id,
code: otp,
deviceId: "ios-device-001",
});
assert.equal(verified.ok, true);
assert.match(verified.session_token, /^[A-Za-z0-9_-]{40,}$/);
assert.doesNotMatch(
JSON.stringify(state.manager.inspectState()),
new RegExp(verified.session_token),
);
const reused = state.manager.verifyOtp({
requestId: requested.request_id,
code: otp,
deviceId: "ios-device-001",
});
assert.equal(reused.ok, false);
assert.equal(reused.error, "invalid_or_expired_code");
});
test("session authentication, expiry, and revocation never return the stored token", async () => {
const state = fixture({ sessionTtlSeconds: 60 });
const requested = await state.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
});
const otp = state.mail[0].text.match(/\b\d{6}\b/)[0];
const verified = state.manager.verifyOtp({
requestId: requested.request_id,
code: otp,
deviceId: "ios-device-001",
});
const active = state.manager.authenticate(
verified.session_token,
"ios-device-001",
);
assert.equal(active.ok, true);
assert.equal(active.session.device_id, "ios-device-001");
assert.equal(Object.hasOwn(active.session, "token"), false);
assert.equal(
state.manager.authenticate(verified.session_token, "ios-device-002").error,
"session_device_mismatch",
);
assert.equal(
state.manager.revoke(verified.session_token, "ios-device-002").error,
"session_device_mismatch",
);
assert.equal(
state.manager.revoke(verified.session_token, "ios-device-001").ok,
true,
);
assert.equal(
state.manager.authenticate(verified.session_token, "ios-device-001").error,
"session_invalid",
);
const second = await state.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
});
const secondOtp = state.mail[1].text.match(/\b\d{6}\b/)[0];
const secondSession = state.manager.verifyOtp({
requestId: second.request_id,
code: secondOtp,
deviceId: "ios-device-001",
});
state.advance(61_000);
assert.equal(
state.manager.authenticate(
secondSession.session_token,
"ios-device-001",
).error,
"session_expired",
);
});
test("OTP expires, locks after five failed attempts, and request rate is bounded", async () => {
const expired = fixture({ otpTtlSeconds: 30 });
const requested = await expired.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
});
const otp = expired.mail[0].text.match(/\b\d{6}\b/)[0];
expired.advance(31_000);
assert.equal(
expired.manager.verifyOtp({
requestId: requested.request_id,
code: otp,
deviceId: "ios-device-001",
}).error,
"invalid_or_expired_code",
);
const locked = fixture({ maxOtpAttempts: 5 });
const lockRequest = await locked.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
});
const realOtp = locked.mail[0].text.match(/\b\d{6}\b/)[0];
for (let attempt = 0; attempt < 5; attempt += 1) {
assert.equal(
locked.manager.verifyOtp({
requestId: lockRequest.request_id,
code: "000000",
deviceId: "ios-device-001",
}).ok,
false,
);
}
assert.equal(
locked.manager.verifyOtp({
requestId: lockRequest.request_id,
code: realOtp,
deviceId: "ios-device-001",
}).error,
"invalid_or_expired_code",
);
const limited = fixture({ requestLimit: 2 });
await limited.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
networkKey: "198.51.100.7",
});
await limited.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
networkKey: "198.51.100.7",
});
const denied = await limited.manager.requestOtp({
email: "owner@example.invalid",
deviceId: "ios-device-001",
networkKey: "198.51.100.7",
});
assert.equal(denied.accepted, false);
assert.equal(denied.error, "rate_limited");
assert.equal(limited.mail.length, 2);
});

View file

@ -10,7 +10,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
install_root=/opt/guanghu/lake-lamp-authz
install -d -m 0755 "$install_root"
for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js; do
for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js hololake-session.js hololake-capabilities.js; do
install -m 0644 "$script_dir/$file" "$install_root/$file"
done
install -m 0644 "$script_dir/lake-lamp-architecture-provision.service" /etc/systemd/system/lake-lamp-architecture-provision.service

View file

@ -17,6 +17,7 @@ PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git
ReadOnlyPaths=-/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git -/etc/guanghu/secrets/hololake-ai-providers.json
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true

View file

@ -10,6 +10,12 @@ const { sendSmtpMail } = require("./smtp-mailer");
const { executeRegisteredAction } = require("./action-client");
const { enqueueDeploymentEvent } = require("./deployment-event");
const { GuanghuRouter, loadDevices } = require("./guanghu-router");
const { HoloLakeSessionManager } = require("./hololake-session");
const {
HoloLakeAiGateway,
HoloLakeKnowledgeProvider,
loadAiProviders,
} = require("./hololake-capabilities");
const {
loadRegistry: loadRepoPushRegistry,
receiveBundle,
@ -71,6 +77,72 @@ function createApp(options = {}) {
smtpUser: process.env.SMTP_USER || ownerEmail,
smtpPass: process.env.QQ_SMTP_AUTH_CODE || "",
}));
const hololakePepper = String(
options.hololakeSessionPepper
|| process.env.HOLOLAKE_SESSION_PEPPER
|| "",
);
const hololakeSessionManager = options.hololakeSessionManager || (
hololakePepper
? new HoloLakeSessionManager({
registeredEmails: approvers.map(item => item.email),
pepper: hololakePepper,
stateFile: Object.prototype.hasOwnProperty.call(options, "hololakeSessionStateFile")
? options.hololakeSessionStateFile
: (
process.env.HOLOLAKE_SESSION_STATE_FILE
|| "/var/lib/guanghu/lake-lamp-authz/hololake-sessions.json"
),
otpTtlSeconds: Number(
options.hololakeOtpTtlSeconds
|| process.env.HOLOLAKE_OTP_TTL
|| 10 * 60,
),
sessionTtlSeconds: Number(
options.hololakeSessionTtlSeconds
|| process.env.HOLOLAKE_ACCOUNT_SESSION_TTL
|| 24 * 60 * 60,
),
requestLimit: Number(
options.hololakeOtpRequestLimit
|| process.env.HOLOLAKE_OTP_REQUEST_LIMIT
|| 6,
),
sendEmail,
})
: null
);
const hololakeKnowledgePath = String(
options.hololakeKnowledgeRepositoryPath
|| process.env.HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH
|| "",
);
const hololakeKnowledgeProvider = options.hololakeKnowledgeProvider || (
hololakeKnowledgePath
? new HoloLakeKnowledgeProvider({
repositoryId: "bingshuo/hololake-knowledge-base",
repositoryPath: hololakeKnowledgePath,
maxArchiveBytes: Number(
options.hololakeKnowledgeMaxArchiveBytes
|| process.env.HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES
|| 128 * 1024 * 1024,
),
})
: null
);
const hololakeAiProvidersFile = String(
options.hololakeAiProvidersFile
|| process.env.HOLOLAKE_AI_PROVIDERS_FILE
|| "",
);
const hololakeAiProviders = options.hololakeAiProviders || (
hololakeAiProvidersFile ? loadAiProviders(hololakeAiProvidersFile) : {}
);
const hololakeAiGateway = options.hololakeAiGateway || (
Object.keys(hololakeAiProviders).length > 0
? new HoloLakeAiGateway({ providers: hololakeAiProviders })
: null
);
const mapGate = options.mapGate || new MapGate({
mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps",
stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"),
@ -139,6 +211,23 @@ function createApp(options = {}) {
return { ok: true, order: issued.order };
}
function authenticateHoloLake(req) {
if (!hololakeSessionManager) {
return { ok: false, status: 503, error: "hololake_session_unavailable" };
}
const token = bearer(req);
if (!token) return { ok: false, status: 401, error: "session_required" };
const deviceId = String(req.headers["x-hololake-device-id"] || "");
const authenticated = hololakeSessionManager.authenticate(token, deviceId);
if (!authenticated.ok) {
return {
...authenticated,
status: authenticated.error === "session_device_mismatch" ? 403 : 401,
};
}
return { ...authenticated, token, deviceId };
}
function bindAndDeliver(created) {
const inspected = manager.inspectHandoff(created.handoffToken);
if (!inspected.ok) return { delivered: 0, approver: null, order: null };
@ -190,6 +279,206 @@ function createApp(options = {}) {
},
});
if (
req.method === "POST"
&& url.pathname === "/api/hololake/session/email/request"
) {
if (!hololakeSessionManager) {
return json(res, 503, failure("hololake_session_unavailable"));
}
const body = await readJson(req);
if (!body) return json(res, 400, failure("invalid_json"));
const headerDeviceId = String(
req.headers["x-hololake-device-id"] || "",
);
if (
headerDeviceId
&& body.device_id
&& !safeEqual(headerDeviceId, String(body.device_id))
) {
return json(res, 400, failure("invalid_device"));
}
const requested = await hololakeSessionManager.requestOtp({
email: body.email,
deviceId: String(body.device_id || headerDeviceId),
networkKey: clientAddress(req),
});
if (!requested.accepted) {
return json(
res,
requested.error === "rate_limited" ? 429 : 400,
failure(requested.error),
);
}
return json(res, 202, {
accepted: true,
request_id: requested.request_id,
expires_in: Number(
options.hololakeOtpTtlSeconds
|| process.env.HOLOLAKE_OTP_TTL
|| 10 * 60,
),
next_step: "如果邮箱已登记,输入邮件中的六位验证码。",
});
}
if (
req.method === "POST"
&& url.pathname === "/api/hololake/session/email/verify"
) {
if (!hololakeSessionManager) {
return json(res, 503, failure("hololake_session_unavailable"));
}
const body = await readJson(req);
if (!body) return json(res, 400, failure("invalid_json"));
const headerDeviceId = String(
req.headers["x-hololake-device-id"] || "",
);
if (
headerDeviceId
&& body.device_id
&& !safeEqual(headerDeviceId, String(body.device_id))
) {
return json(res, 400, failure("invalid_device"));
}
const verified = hololakeSessionManager.verifyOtp({
requestId: body.request_id,
code: body.code,
deviceId: String(body.device_id || headerDeviceId),
});
if (!verified.ok) return json(res, 403, failure(verified.error));
return json(res, 200, verified);
}
if (
(req.method === "GET" || req.method === "DELETE")
&& url.pathname === "/api/hololake/session"
) {
const authenticated = authenticateHoloLake(req);
if (!authenticated.ok) {
return json(
res,
authenticated.status,
failure(authenticated.error),
);
}
if (req.method === "DELETE") {
const revoked = hololakeSessionManager.revoke(
authenticated.token,
authenticated.deviceId,
);
return json(res, revoked.ok ? 200 : 401, revoked);
}
return json(res, 200, {
ok: true,
session: authenticated.session,
});
}
if (
req.method === "GET"
&& url.pathname === "/api/hololake/knowledge/manifest"
) {
const authenticated = authenticateHoloLake(req);
if (!authenticated.ok) {
return json(
res,
authenticated.status,
failure(authenticated.error),
);
}
if (!hololakeKnowledgeProvider) {
return json(res, 503, failure("knowledge_repository_unavailable"));
}
try {
return json(res, 200, hololakeKnowledgeProvider.manifest());
} catch {
return json(res, 503, failure("knowledge_repository_unavailable"));
}
}
if (
req.method === "GET"
&& url.pathname === "/api/hololake/knowledge/archive"
) {
const authenticated = authenticateHoloLake(req);
if (!authenticated.ok) {
return json(
res,
authenticated.status,
failure(authenticated.error),
);
}
if (!hololakeKnowledgeProvider) {
return json(res, 503, failure("knowledge_repository_unavailable"));
}
try {
const archive = hololakeKnowledgeProvider.archive(
url.searchParams.get("commit"),
);
res.writeHead(200, {
"content-type": archive.content_type,
"content-length": archive.body.length,
"content-disposition": `attachment; filename="hololake-knowledge-${archive.commit}.zip"`,
"cache-control": "private, no-store",
"x-content-type-options": "nosniff",
"x-hololake-commit": archive.commit,
"x-content-sha256": archive.sha256,
});
res.end(archive.body);
return;
} catch (error) {
const code = error && error.message === "knowledge_commit_not_current"
? "knowledge_commit_not_current"
: "knowledge_archive_unavailable";
return json(res, code === "knowledge_commit_not_current" ? 409 : 503, failure(code));
}
}
if (
req.method === "GET"
&& url.pathname === "/api/hololake/ai/catalog"
) {
const authenticated = authenticateHoloLake(req);
if (!authenticated.ok) {
return json(
res,
authenticated.status,
failure(authenticated.error),
);
}
if (!hololakeAiGateway) {
return json(res, 503, failure("ai_gateway_unavailable"));
}
return json(res, 200, hololakeAiGateway.catalog());
}
if (
req.method === "POST"
&& url.pathname === "/api/hololake/ai/execute"
) {
const authenticated = authenticateHoloLake(req);
if (!authenticated.ok) {
return json(
res,
authenticated.status,
failure(authenticated.error),
);
}
if (!hololakeAiGateway) {
return json(res, 503, failure("ai_gateway_unavailable"));
}
const body = await readJson(req);
if (!body) return json(res, 400, failure("invalid_json"));
try {
return json(res, 200, await hololakeAiGateway.execute(body));
} catch (error) {
const code = String(error && error.message || "ai_gateway_failed");
const status = code.startsWith("ai_upstream_") ? 502 : 400;
return json(res, status, failure(code));
}
}
if (req.method === "POST" && url.pathname === "/api/repositories/resolve") {
const body = await readJson(req);
if (!body) return json(res, 400, failure("invalid_json"));