myphoneserver 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/numeric.mjs +22 -0
- package/package.json +22 -6
- package/pair.mjs +49 -13
package/README.md
CHANGED
|
@@ -5,13 +5,15 @@ relay cannot create a valid management request or read its code/log payloads.
|
|
|
5
5
|
A compatible phone APK and relay `owner/call` transport are required. The old remote
|
|
6
6
|
MCP connection alone cannot manage an upgraded phone.
|
|
7
7
|
|
|
8
|
-
## Connect (
|
|
8
|
+
## Connect (QR or numeric code)
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
11
|
npx -y myphoneserver connect
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The PC opens a QR
|
|
14
|
+
The PC opens a page with a QR and an eight-digit connection code. If scanning is unavailable, enter that code in the phone's **PC 연결 → 번호로 연결**. Compare the separate eight-digit confirmation number on the phone and this PC; confirm only if they match. The connection code expires after ten minutes and only locates a session: it does not authorize a PC.
|
|
15
|
+
|
|
16
|
+
For QR, on the phone: MyPhoneServer → **PC 연결** → scan the QR → **연결**. The phone enrolls this
|
|
15
17
|
PC's public key from the QR (a local, optical channel) and returns its pairing and transport details encrypted with
|
|
16
18
|
the QR's one-time secret through a relay mailbox (`/__pocket/pair/<id>`, 10 minutes, handed over once). The relay can
|
|
17
19
|
neither read nor forge that reply. The command then registers the `myphoneserver` stdio MCP server with Claude Code
|
|
@@ -64,4 +66,4 @@ failure, inspect status before sending another deployment; the adapter does not
|
|
|
64
66
|
silently retry a command that may already have executed.
|
|
65
67
|
|
|
66
68
|
This protects the management channel, not the ordinary public website traffic.
|
|
67
|
-
See [security scope and remaining limits](
|
|
69
|
+
See [security scope and remaining limits](https://github.com/enabletech-ai/pocketserver/blob/main/docs/security.md).
|
package/numeric.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// An eight-digit rendezvous locates the session; it is NOT an authorization secret.
|
|
2
|
+
// Commit before the phone's fresh contribution; compare the derived number on both screens.
|
|
3
|
+
import {randomInt,randomBytes,createHash,createPublicKey,createPrivateKey,diffieHellman,hkdfSync} from 'node:crypto';
|
|
4
|
+
import {keyPair} from './crypto.mjs';
|
|
5
|
+
const DOMAIN='pocket-number-v1';
|
|
6
|
+
const hash=v=>createHash('sha256').update(v).digest();
|
|
7
|
+
const text=(code,r)=>[DOMAIN,code,r.owner,r.key,r.nonce,r.name].join('\n');
|
|
8
|
+
export const commitment=(code,r)=>hash(text(code,r)).toString('base64url');
|
|
9
|
+
export function numericSession(owner,name){
|
|
10
|
+
const code=String(randomInt(100000000)).padStart(8,'0'),ephemeral=keyPair();
|
|
11
|
+
const reveal={owner,key:ephemeral.publicKey,nonce:randomBytes(32).toString('base64url'),name:name.slice(0,40).replace(/[\r\n]/g,' ')};
|
|
12
|
+
return {code,reveal,commit:commitment(code,reveal),finish(hello){
|
|
13
|
+
if(!/^[A-Za-z0-9_-]{43}$/.test(hello?.nonce||''))throw Error('Invalid phone nonce');
|
|
14
|
+
const publicKey=createPublicKey({key:Buffer.from(hello.key,'base64'),type:'spki',format:'der'});
|
|
15
|
+
if(publicKey.asymmetricKeyDetails?.namedCurve!=='prime256v1')throw Error('Invalid phone curve');
|
|
16
|
+
const transcript=text(code,reveal)+'\n'+hello.key+'\n'+hello.nonce,digest=hash(transcript);
|
|
17
|
+
const shared=diffieHellman({privateKey:createPrivateKey(ephemeral.privateKey),publicKey});
|
|
18
|
+
const secret=Buffer.from(hkdfSync('sha256',shared,digest,DOMAIN,32));
|
|
19
|
+
const comparison=String(hash(Buffer.concat([secret,Buffer.from(transcript)])).readUInt32BE()%100000000).padStart(8,'0');
|
|
20
|
+
return {id:digest.toString('base64url'),secret:secret.toString('base64url'),comparison};
|
|
21
|
+
}};
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myphoneserver",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Connect Claude Code or Codex to your MyPhoneServer phone and deploy apps to it.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"myphoneserver": "client.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"client.mjs",
|
|
11
|
+
"crypto.mjs",
|
|
12
|
+
"pair.mjs",
|
|
13
|
+
"numeric.mjs",
|
|
14
|
+
"brand.mjs",
|
|
15
|
+
"lib/",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"qrcode": "1.5.4"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prepack": "mkdir -p lib && cp ../../relay/plan.mjs ../../relay/safety.mjs lib/"
|
|
26
|
+
}
|
|
11
27
|
}
|
package/pair.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import http from 'node:http';
|
|
|
7
7
|
import {spawn,spawnSync} from 'node:child_process';
|
|
8
8
|
import {fileURLToPath} from 'node:url';
|
|
9
9
|
import QRCode from 'qrcode';
|
|
10
|
+
import {numericSession} from './numeric.mjs';
|
|
10
11
|
import {logoSvg,brandCss} from './brand.mjs';
|
|
11
12
|
import {keyPair,fingerprint,pairSession,openPair} from './crypto.mjs';
|
|
12
13
|
|
|
@@ -63,41 +64,75 @@ function openBrowser(url){
|
|
|
63
64
|
const [bin,args]=process.platform==='darwin'?['open',[url]]:process.platform==='win32'?['cmd',['/c','start','',url]]:['xdg-open',[url]];
|
|
64
65
|
try{spawn(bin,args,{stdio:'ignore',detached:true}).unref();}catch{}
|
|
65
66
|
}
|
|
66
|
-
const page=(svg)=>`<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PC 연결 · MyPhoneServer</title>
|
|
67
|
+
const page=(svg,code)=>`<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PC 연결 · MyPhoneServer</title>
|
|
67
68
|
<style>${brandCss}*{box-sizing:border-box}body{margin:0;background:var(--brand-background);color:var(--brand-ink);font:14px/1.6 -apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo',sans-serif;letter-spacing:-.25px}header{border-bottom:1px solid var(--brand-line);background:white;padding:20px;display:flex;justify-content:center}header svg{width:205px;height:51px}main{width:420px;max-width:calc(100% - 32px);margin:38px auto;background:white;border:1px solid var(--brand-line);border-radius:14px;padding:30px;text-align:center}h1{font-size:25px;letter-spacing:-1px;margin:0 0 10px}p{font-size:12px;color:var(--brand-muted);margin:0 0 22px}.qr{padding:14px;background:white;border:1px solid var(--brand-line);border-radius:12px;display:inline-flex}.qr svg{width:250px;height:250px;max-width:100%}#s{font-size:12px;color:var(--brand-blue);padding:12px;background:var(--brand-soft);border-radius:7px;margin:22px 0 0}@media(max-width:480px){main{margin:25px auto;padding:25px 20px}.qr svg{width:235px;height:235px}}</style>
|
|
68
|
-
<header>${logoSvg}</header><main><h1>폰과 연결하기</h1><p>폰
|
|
69
|
-
<script>setInterval(async()=>{try{const r=await (await fetch('/status')).json();document.getElementById('s').textContent=r.message;if(r.state!=='waiting')document.querySelector('.qr svg').style.opacity=.15;}catch{}},1500)</script></html>`;
|
|
69
|
+
<header>${logoSvg}</header><main><h1>폰과 연결하기</h1><p>폰 앱의 <b>PC 연결</b>에서 QR을 찍거나 아래 번호를 입력하세요.</p><h2 id="code">${code?code.slice(0,4)+' '+code.slice(4):'번호 연결을 준비하지 못했어요. 다시 실행해 주세요.'}</h2><p id="comparison"></p><div class="qr">${svg}</div><p id="s" role="status">기다리는 중…</p></main>
|
|
70
|
+
<script>setInterval(async()=>{try{const r=await (await fetch('/status')).json();document.getElementById('s').textContent=r.message;document.getElementById('comparison').textContent=r.comparison?'확인번호: '+r.comparison.slice(0,4)+' '+r.comparison.slice(4)+' · 폰의 번호와 같을 때만 연결하세요.':'';if(r.state!=='waiting')document.querySelector('.qr svg').style.opacity=.15;}catch{}},1500)</script></html>`;
|
|
71
|
+
|
|
72
|
+
// Legacy installations keep their relay during account sign-in. Reuse only a locally
|
|
73
|
+
// trusted transport for the exact same endpoint; never obtain its key from the relay.
|
|
74
|
+
export function pairTransport(reply,previous,fallbackKey){
|
|
75
|
+
const mcpUrl=reply.mcpUrl||(reply.relayOrigin&&reply.relayOrigin+'/mcp');
|
|
76
|
+
if(!mcpUrl)throw Error('Phone transport missing');
|
|
77
|
+
const parsed=new URL(mcpUrl);
|
|
78
|
+
if(parsed.protocol!=='https:' || parsed.username || parsed.password)throw Error('Invalid phone transport');
|
|
79
|
+
const mcpKey=reply.mcpKey||fallbackKey||(previous?.mcpUrl===mcpUrl?previous.mcpKey:null);
|
|
80
|
+
if(!mcpKey)throw Error('Phone account transport missing');
|
|
81
|
+
return {mcpUrl,mcpKey};
|
|
82
|
+
}
|
|
70
83
|
|
|
71
84
|
// Background worker: shows the QR page and waits for the phone (up to 10 minutes).
|
|
72
85
|
async function worker(home){
|
|
73
86
|
const owner=ownerKey(home),session=pairSession();
|
|
87
|
+
let previous;
|
|
88
|
+
try{const file=path.join(home,'connection.json'),stat=fs.lstatSync(file);
|
|
89
|
+
if(stat.isFile() && !stat.isSymbolicLink() && !(stat.mode&0o077))previous=JSON.parse(fs.readFileSync(file,'utf8'));
|
|
90
|
+
}catch{}
|
|
74
91
|
const qr=`myphoneserver:pair?v=1&id=${session.id}&s=${session.secret}&k=${encodeURIComponent(owner.publicKey)}&n=${encodeURIComponent(os.hostname().replace(/\.local$/,'').slice(0,40))}`;
|
|
75
92
|
const svg=await QRCode.toString(qr,{type:'svg',margin:1,errorCorrectionLevel:'M'});
|
|
76
|
-
let
|
|
93
|
+
let numeric;
|
|
94
|
+
if(!process.env.POCKET_TEST)for(let i=0;i<4;i++){
|
|
95
|
+
const candidate=numericSession(owner.publicKey,os.hostname().replace(/\.local$/,'').slice(0,40));
|
|
96
|
+
try{const r=await fetch(`${RELAY}/__pocket/number/${candidate.code}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({commit:candidate.commit}),signal:AbortSignal.timeout(10000)});
|
|
97
|
+
if(r.status===409)continue;if(r.ok)numeric=candidate;}catch{}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
let numberPair;
|
|
101
|
+
let status={state:'waiting',message:'QR을 찍거나 폰에서 연결 번호를 입력해 주세요.',...(numeric?{code:numeric.code}:{})};
|
|
77
102
|
const server=http.createServer((req,res)=>{
|
|
78
103
|
if(req.headers.host!==`127.0.0.1:${server.address().port}`){res.writeHead(403).end();return;} // no DNS rebinding
|
|
79
104
|
if(req.url==='/status'){res.writeHead(200,{'Content-Type':'application/json','Cache-Control':'no-store'}).end(JSON.stringify(status));return;}
|
|
80
|
-
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}).end(page(svg));
|
|
105
|
+
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}).end(page(svg,numeric?.code));
|
|
81
106
|
});
|
|
82
107
|
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
83
108
|
const url=`http://127.0.0.1:${server.address().port}/`;
|
|
84
109
|
// POCKET_TEST: automated pairing test with a temporary home; no browser, no client registration.
|
|
85
110
|
// The QR text also goes to the command output so cloud sessions (no local browser) can show it in the chat.
|
|
86
|
-
setState(home,{
|
|
111
|
+
setState(home,{...status,url,qr});if(!process.env.POCKET_TEST)openBrowser(url);
|
|
87
112
|
const finish=(state,message)=>{status={state,message};setState(home,{state,url,message});setTimeout(()=>process.exit(0),5000);};
|
|
88
113
|
const until=Date.now()+WAIT_MS;
|
|
89
114
|
while(Date.now()<until){
|
|
90
115
|
await new Promise(r=>setTimeout(r,2000));
|
|
91
|
-
|
|
116
|
+
if(numeric && !numberPair){
|
|
117
|
+
try{const hello=await fetch(`${RELAY}/__pocket/number/${numeric.code}/hello`,{signal:AbortSignal.timeout(10000)});
|
|
118
|
+
if(hello.ok){numberPair=numeric.finish(await hello.json());
|
|
119
|
+
const reveal=await fetch(`${RELAY}/__pocket/number/${numeric.code}/reveal`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(numeric.reveal),signal:AbortSignal.timeout(10000)});
|
|
120
|
+
if(!reveal.ok)throw Error('reveal failed');
|
|
121
|
+
status={...status,comparison:numberPair.comparison,message:'폰의 확인번호와 같을 때만 폰에서 연결을 누르세요.'};setState(home,{...status,url,qr});
|
|
122
|
+
}
|
|
123
|
+
}catch{return finish('error','번호 연결을 확인하지 못했어요. 다시 연결해 주세요.');}
|
|
124
|
+
}
|
|
125
|
+
let response,selected=session;
|
|
126
|
+
if(numberPair){try{response=await fetch(`${RELAY}/__pocket/pair/${numberPair.id}`,{signal:AbortSignal.timeout(10000)});if(response.ok)selected=numberPair;}catch{}}
|
|
127
|
+
if(!response?.ok)try{response=await fetch(`${RELAY}/__pocket/pair/${session.id}`,{redirect:'error',signal:AbortSignal.timeout(10000)});}catch{continue;}
|
|
92
128
|
if(response.status===404)continue;
|
|
93
129
|
if(!response.ok)return finish('error','연결하지 못했어요. 다시 시도해 주세요.');
|
|
94
130
|
let reply;
|
|
95
|
-
try{reply=openPair(
|
|
131
|
+
try{reply=openPair(selected.id,selected.secret,await response.json());}catch{return finish('error','연결하지 못했어요. 다시 시도해 주세요.');}
|
|
96
132
|
if(reply.version!==1 || reply.pairing?.version!==1 || reply.pairing.ownerFingerprint!==fingerprint(owner.publicKey))return finish('error','다른 PC의 연결 정보예요. 다시 시도해 주세요.');
|
|
97
|
-
|
|
98
|
-
if(!mcpUrl || !mcpKey || new URL(mcpUrl).protocol!=='https:')return finish('error','폰 앱에서 Google 로그인을 먼저 해 주세요.');
|
|
133
|
+
let config;try{config=pairTransport(reply,previous,process.env.POCKET_MCP_KEY);}catch{return finish('error','폰의 관리 연결 정보가 없어요. 계정 연결을 완료하거나 기존 PC에서 다시 연결해 주세요.');}
|
|
99
134
|
writePrivate(home,'phone.json',JSON.stringify(reply.pairing));
|
|
100
|
-
writePrivate(home,'connection.json',JSON.stringify(
|
|
135
|
+
writePrivate(home,'connection.json',JSON.stringify(config));
|
|
101
136
|
const clients=process.env.POCKET_TEST?[]:registerClients(home);
|
|
102
137
|
return finish('done',`연결됐어요!${clients.length?` ${clients.join('·')}에서 바로 쓸 수 있어요.`:''} 이 창은 닫아도 돼요.`);
|
|
103
138
|
}
|
|
@@ -109,11 +144,12 @@ export async function connect(home,args){
|
|
|
109
144
|
if(args.includes('--worker'))return worker(home);
|
|
110
145
|
try{fs.unlinkSync(stateFile(home));}catch{}
|
|
111
146
|
const child=spawn(process.execPath,[script,'connect','--worker'],{detached:true,stdio:'ignore',env:process.env});child.unref();
|
|
112
|
-
let shown=false;
|
|
147
|
+
let shown=false,comparisonShown=false;
|
|
113
148
|
for(const until=Date.now()+90000;Date.now()<until;){
|
|
114
149
|
await new Promise(r=>setTimeout(r,1000));
|
|
115
150
|
const s=readState(home);if(!s)continue;
|
|
116
|
-
if(!shown && s.url){console.log(`${await QRCode.toString(s.qr,{type:'utf8',margin:2,errorCorrectionLevel:'M'})}\n폰의 MyPhoneServer 앱에서 [PC 연결]을 누르고 위 QR을 찍어 주세요. (이 PC의 브라우저에도 열었어요: ${s.url})`);shown=true;}
|
|
151
|
+
if(!shown && s.url){console.log(`${await QRCode.toString(s.qr,{type:'utf8',margin:2,errorCorrectionLevel:'M'})}\n폰의 MyPhoneServer 앱에서 [PC 연결]을 누르고 위 QR을 찍어 주세요. (이 PC의 브라우저에도 열었어요: ${s.url})`);shown=true;if(s.code)console.log('연결 번호: '+s.code.slice(0,4)+' '+s.code.slice(4));}
|
|
152
|
+
if(s.comparison && !comparisonShown){console.log('확인번호: '+s.comparison.slice(0,4)+' '+s.comparison.slice(4)+' · 폰과 같을 때만 연결하세요.');comparisonShown=true;}
|
|
117
153
|
if(s.state==='done'){console.log(s.message);return;}
|
|
118
154
|
if(s.state!=='waiting'){console.log(s.message);process.exitCode=1;return;}
|
|
119
155
|
}
|